mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
custom SSH port for Borg remote targets
feat(host-backup): custom SSH port for Borg remote targets Add Borg destinations no longer assume port 22. The Monitor form and the shell TUI both take an optional port (default 22, range 1-65535) and embed it in the persisted ssh://user@host:port/path URL. BORG_RSH, the sshpass key installer and the capacity probe all honour it. Reported by @songochain in #236 — NAS-style Borg hosts on non-standard SSH ports are now first-class targets. Existing borg-targets.txt entries without a port keep working; the port is only serialised into the URL when it differs from 22.
This commit is contained in:
@@ -2717,6 +2717,11 @@ interface BorgRepo {
|
|||||||
name: string
|
name: string
|
||||||
repository: string
|
repository: string
|
||||||
ssh_key_path?: string
|
ssh_key_path?: string
|
||||||
|
// Parsed from `ssh://user@host[:port]/path`; 22 for URLs without an
|
||||||
|
// explicit port and 0 for local-mode targets. Newer backends ship
|
||||||
|
// this; older ones don't and the frontend falls back to re-parsing
|
||||||
|
// the URL on the fly.
|
||||||
|
ssh_port?: number
|
||||||
// Encryption + saved-passphrase metadata. Newer backends ship these;
|
// Encryption + saved-passphrase metadata. Newer backends ship these;
|
||||||
// older deployments without the fields default to "repokey" (the
|
// older deployments without the fields default to "repokey" (the
|
||||||
// shell installer's historical default) and unknown-passphrase.
|
// shell installer's historical default) and unknown-passphrase.
|
||||||
@@ -6092,6 +6097,11 @@ function AddDestinationDialog({
|
|||||||
const [borgMode, setBorgMode] = useState<"local" | "ssh">("local")
|
const [borgMode, setBorgMode] = useState<"local" | "ssh">("local")
|
||||||
const [borgSshUser, setBorgSshUser] = useState("borg")
|
const [borgSshUser, setBorgSshUser] = useState("borg")
|
||||||
const [borgSshHost, setBorgSshHost] = useState("")
|
const [borgSshHost, setBorgSshHost] = useState("")
|
||||||
|
// Custom SSH port for NAS-style hosts that expose SSH on non-22.
|
||||||
|
// Kept as string in state so the input can start empty; converted to
|
||||||
|
// integer at submit time. Empty / "22" means "use the default port"
|
||||||
|
// and is omitted from the ssh:// URL by the backend.
|
||||||
|
const [borgSshPort, setBorgSshPort] = useState("22")
|
||||||
const [borgSshRemotePath, setBorgSshRemotePath] = useState("")
|
const [borgSshRemotePath, setBorgSshRemotePath] = useState("")
|
||||||
const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg")
|
const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg")
|
||||||
const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null)
|
const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null)
|
||||||
@@ -6148,26 +6158,32 @@ function AddDestinationDialog({
|
|||||||
setUsername(editing.username || "root@pam")
|
setUsername(editing.username || "root@pam")
|
||||||
setFingerprint(editing.fingerprint || "")
|
setFingerprint(editing.fingerprint || "")
|
||||||
setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg")
|
setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg")
|
||||||
setBorgSshHost(""); setBorgSshRemotePath("")
|
setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
||||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||||
setBorgEncryptionEnabled(true); setLocalPath("")
|
setBorgEncryptionEnabled(true); setLocalPath("")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (editing && editing.kind === "borg") {
|
if (editing && editing.kind === "borg") {
|
||||||
const repo = editing.repository || ""
|
const repo = editing.repository || ""
|
||||||
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/)
|
// Match ssh://user@host[:port]/path — the port group is optional.
|
||||||
|
// Group 3 captures the numeric port when present, group 4 the path.
|
||||||
|
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/:]+)(?::(\d+))?\/(.+)$/)
|
||||||
setName(editing.name)
|
setName(editing.name)
|
||||||
if (ssh) {
|
if (ssh) {
|
||||||
setBorgMode("ssh")
|
setBorgMode("ssh")
|
||||||
setBorgSshUser(ssh[1])
|
setBorgSshUser(ssh[1])
|
||||||
setBorgSshHost(ssh[2])
|
setBorgSshHost(ssh[2])
|
||||||
setBorgSshRemotePath(`/${ssh[3]}`)
|
// Prefer the port already parsed by the backend (ssh_port); fall
|
||||||
|
// back to the URL match; default 22 when neither is set.
|
||||||
|
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
|
||||||
|
setBorgSshPort(String(port || 22))
|
||||||
|
setBorgSshRemotePath(`/${ssh[4]}`)
|
||||||
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
||||||
setBorgRepo("")
|
setBorgRepo("")
|
||||||
} else {
|
} else {
|
||||||
setBorgMode("local")
|
setBorgMode("local")
|
||||||
setBorgRepo(repo)
|
setBorgRepo(repo)
|
||||||
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("")
|
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
||||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||||
}
|
}
|
||||||
const mode = editing.encrypt_mode || "repokey"
|
const mode = editing.encrypt_mode || "repokey"
|
||||||
@@ -6186,6 +6202,7 @@ function AddDestinationDialog({
|
|||||||
setBorgMode("local")
|
setBorgMode("local")
|
||||||
setBorgSshUser("borg")
|
setBorgSshUser("borg")
|
||||||
setBorgSshHost("")
|
setBorgSshHost("")
|
||||||
|
setBorgSshPort("22")
|
||||||
setBorgSshRemotePath("")
|
setBorgSshRemotePath("")
|
||||||
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
||||||
setBorgEncryptionEnabled(true)
|
setBorgEncryptionEnabled(true)
|
||||||
@@ -6274,6 +6291,12 @@ function AddDestinationDialog({
|
|||||||
body.ssh_host = borgSshHost.trim()
|
body.ssh_host = borgSshHost.trim()
|
||||||
body.ssh_remote_path = borgSshRemotePath.trim()
|
body.ssh_remote_path = borgSshRemotePath.trim()
|
||||||
if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim()
|
if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim()
|
||||||
|
// Only send port when it's non-default; the backend leaves 22
|
||||||
|
// out of the persisted URL so existing targets stay identical.
|
||||||
|
const portNum = parseInt(borgSshPort.trim(), 10)
|
||||||
|
if (Number.isFinite(portNum) && portNum > 0 && portNum !== 22) {
|
||||||
|
body.ssh_port = portNum
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", {
|
const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -6409,9 +6432,23 @@ function AddDestinationDialog({
|
|||||||
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
|
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-[1fr_100px] gap-3">
|
||||||
<Label htmlFor="borgSshHost">{t("backup.fields.sshHostOrIp")}</Label>
|
<div>
|
||||||
<Input id="borgSshHost" value={borgSshHost} onChange={(e) => setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
|
<Label htmlFor="borgSshHost">{t("backup.fields.sshHostOrIp")}</Label>
|
||||||
|
<Input id="borgSshHost" value={borgSshHost} onChange={(e) => setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="borgSshPort">{t("backup.fields.sshPort")}</Label>
|
||||||
|
<Input
|
||||||
|
id="borgSshPort"
|
||||||
|
value={borgSshPort}
|
||||||
|
onChange={(e) => setBorgSshPort(e.target.value.replace(/[^\d]/g, ""))}
|
||||||
|
className="font-mono mt-1"
|
||||||
|
placeholder="22"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="borgSshPath">{t("backup.fields.remoteRepositoryPath")}</Label>
|
<Label htmlFor="borgSshPath">{t("backup.fields.remoteRepositoryPath")}</Label>
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ function suggestPackageName(name: string) {
|
|||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The argv editors use a comma-separated display value, while the API stores
|
||||||
|
// each argument as an array item. Keep this conversion separate from the text
|
||||||
|
// shown in the controlled input: normalising the visible value on every
|
||||||
|
// keystroke would remove a newly typed comma or trailing space before the user
|
||||||
|
// can enter the next argument.
|
||||||
|
function parseArgvInput(value: string): string[] {
|
||||||
|
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
|
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const isLightTheme = useIsLightTheme()
|
const isLightTheme = useIsLightTheme()
|
||||||
@@ -371,6 +380,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||||
// Editor state
|
// Editor state
|
||||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
||||||
|
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||||
|
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testingDetector, setTestingDetector] = useState(false)
|
const [testingDetector, setTestingDetector] = useState(false)
|
||||||
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
const [detectorTest, setDetectorTest] = useState<DetectorTestResult | null>(null)
|
||||||
@@ -742,6 +753,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
setShowAdvanced(false)
|
setShowAdvanced(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
||||||
|
setCommandArgvInput((seed.command_argv || []).join(", "))
|
||||||
setEditing({ appId: existing?.id || null, draft: seed })
|
setEditing({ appId: existing?.id || null, draft: seed })
|
||||||
setDetectorTest(null)
|
setDetectorTest(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -749,6 +762,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
|
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setBinaryArgsInput("")
|
||||||
|
setCommandArgvInput("")
|
||||||
setDetectorTest(null)
|
setDetectorTest(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
@@ -771,6 +786,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
setSidecar(r)
|
setSidecar(r)
|
||||||
setLxcAppsCached(vmid, r, suggestions)
|
setLxcAppsCached(vmid, r, suggestions)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setBinaryArgsInput("")
|
||||||
|
setCommandArgvInput("")
|
||||||
onChange?.()
|
onChange?.()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||||
@@ -1241,6 +1258,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
patch.distribution = t.distribution || ""
|
patch.distribution = t.distribution || ""
|
||||||
patch.container_name = t.container_name || ""
|
patch.container_name = t.container_name || ""
|
||||||
patch.label = t.label || ""
|
patch.label = t.label || ""
|
||||||
|
patch.command_argv = t.command_argv || []
|
||||||
patch.installed_regex = t.installed_regex || ""
|
patch.installed_regex = t.installed_regex || ""
|
||||||
patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "")
|
patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "")
|
||||||
patch.repo = t.repo || ""
|
patch.repo = t.repo || ""
|
||||||
@@ -1249,6 +1267,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
patch.upstream_json_path = (t as any).upstream_json_path || ""
|
patch.upstream_json_path = (t as any).upstream_json_path || ""
|
||||||
patch.docker_image = (t as any).docker_image || ""
|
patch.docker_image = (t as any).docker_image || ""
|
||||||
patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)"
|
patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)"
|
||||||
|
setBinaryArgsInput((t.binary_args || []).join(", "))
|
||||||
|
setCommandArgvInput((t.command_argv || []).join(", "))
|
||||||
setShowAdvanced(true)
|
setShowAdvanced(true)
|
||||||
}
|
}
|
||||||
setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev)
|
setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev)
|
||||||
@@ -1713,10 +1733,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
|
<Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="app-de-args"
|
id="app-de-args"
|
||||||
value={(draft.binary_args || []).join(", ")}
|
value={binaryArgsInput}
|
||||||
onChange={(e) => setField({
|
onChange={(e) => {
|
||||||
binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
const value = e.target.value
|
||||||
})}
|
setBinaryArgsInput(value)
|
||||||
|
setField({ binary_args: parseArgvInput(value) })
|
||||||
|
}}
|
||||||
placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
|
placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
@@ -1732,10 +1754,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
|
|||||||
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
|
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="app-cmd-argv"
|
id="app-cmd-argv"
|
||||||
value={(draft.command_argv || []).join(", ")}
|
value={commandArgvInput}
|
||||||
onChange={(e) => setField({
|
onChange={(e) => {
|
||||||
command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean),
|
const value = e.target.value
|
||||||
})}
|
setCommandArgvInput(value)
|
||||||
|
setField({ command_argv: parseArgvInput(value) })
|
||||||
|
}}
|
||||||
placeholder={t("vmLxc.appEditor.commandPlaceholder")}
|
placeholder={t("vmLxc.appEditor.commandPlaceholder")}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VMs/LXCs",
|
"vmsLxcs": "VMs/LXCs",
|
||||||
"when": "Wann",
|
"when": "Wann",
|
||||||
"whenLabel": "Wann",
|
"whenLabel": "Wann",
|
||||||
"zfsPools": "ZFS-Pools"
|
"zfsPools": "ZFS-Pools",
|
||||||
|
"sshPort": "SSH-Port"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "Backups.",
|
"attachDescriptionAfter": "Backups.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VMs/LXCs",
|
"vmsLxcs": "VMs/LXCs",
|
||||||
"when": "When",
|
"when": "When",
|
||||||
"whenLabel": "When",
|
"whenLabel": "When",
|
||||||
"zfsPools": "ZFS pools"
|
"zfsPools": "ZFS pools",
|
||||||
|
"sshPort": "SSH port"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "backups.",
|
"attachDescriptionAfter": "backups.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VM/LXC",
|
"vmsLxcs": "VM/LXC",
|
||||||
"when": "Cuando",
|
"when": "Cuando",
|
||||||
"whenLabel": "Cuando",
|
"whenLabel": "Cuando",
|
||||||
"zfsPools": "grupos ZFS"
|
"zfsPools": "grupos ZFS",
|
||||||
|
"sshPort": "Puerto SSH"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "copias de seguridad.",
|
"attachDescriptionAfter": "copias de seguridad.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VM/LXC",
|
"vmsLxcs": "VM/LXC",
|
||||||
"when": "Quand",
|
"when": "Quand",
|
||||||
"whenLabel": "Quand",
|
"whenLabel": "Quand",
|
||||||
"zfsPools": "Pools ZFS"
|
"zfsPools": "Pools ZFS",
|
||||||
|
"sshPort": "Port SSH"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "sauvegardes.",
|
"attachDescriptionAfter": "sauvegardes.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VM/LXC",
|
"vmsLxcs": "VM/LXC",
|
||||||
"when": "Quando",
|
"when": "Quando",
|
||||||
"whenLabel": "Quando",
|
"whenLabel": "Quando",
|
||||||
"zfsPools": "Pool ZFS"
|
"zfsPools": "Pool ZFS",
|
||||||
|
"sshPort": "Porta SSH"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "backup.",
|
"attachDescriptionAfter": "backup.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VMs/LXCs",
|
"vmsLxcs": "VMs/LXCs",
|
||||||
"when": "Quando",
|
"when": "Quando",
|
||||||
"whenLabel": "Quando",
|
"whenLabel": "Quando",
|
||||||
"zfsPools": "Conjuntos ZFS"
|
"zfsPools": "Conjuntos ZFS",
|
||||||
|
"sshPort": "Porta SSH"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "cópias de segurança.",
|
"attachDescriptionAfter": "cópias de segurança.",
|
||||||
|
|||||||
@@ -3962,7 +3962,8 @@
|
|||||||
"vmsLxcs": "VM/LXC",
|
"vmsLxcs": "VM/LXC",
|
||||||
"when": "Kedy",
|
"when": "Kedy",
|
||||||
"whenLabel": "Kedy",
|
"whenLabel": "Kedy",
|
||||||
"zfsPools": "ZFS pooly"
|
"zfsPools": "ZFS pooly",
|
||||||
|
"sshPort": "SSH port"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "zálohy.",
|
"attachDescriptionAfter": "zálohy.",
|
||||||
|
|||||||
@@ -3963,7 +3963,8 @@
|
|||||||
"vmsLxcs": "virtuella datorer/LXC:er",
|
"vmsLxcs": "virtuella datorer/LXC:er",
|
||||||
"when": "När",
|
"when": "När",
|
||||||
"whenLabel": "När",
|
"whenLabel": "När",
|
||||||
"zfsPools": "ZFS pooler"
|
"zfsPools": "ZFS pooler",
|
||||||
|
"sshPort": "SSH-port"
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"attachDescriptionAfter": "säkerhetskopior.",
|
"attachDescriptionAfter": "säkerhetskopior.",
|
||||||
|
|||||||
@@ -16380,11 +16380,29 @@ def _list_borg_destinations() -> list:
|
|||||||
encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey'
|
encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey'
|
||||||
pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt'
|
pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt'
|
||||||
has_passphrase = os.path.isfile(pass_file)
|
has_passphrase = os.path.isfile(pass_file)
|
||||||
|
# Parse ssh://user@host[:port]/path so the frontend can
|
||||||
|
# display the port without having to re-parse the URL.
|
||||||
|
# Non-ssh targets (local paths) leave ssh_port at 0.
|
||||||
|
ssh_port = 0
|
||||||
|
if repo.startswith('ssh://'):
|
||||||
|
after_scheme = repo[len('ssh://'):]
|
||||||
|
at_split = after_scheme.split('@', 1)
|
||||||
|
if len(at_split) == 2:
|
||||||
|
host_and_path = at_split[1]
|
||||||
|
host_part = host_and_path.split('/', 1)[0]
|
||||||
|
if ':' in host_part:
|
||||||
|
try:
|
||||||
|
ssh_port = int(host_part.rsplit(':', 1)[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
ssh_port = 0
|
||||||
|
if ssh_port == 0:
|
||||||
|
ssh_port = 22
|
||||||
targets.append({
|
targets.append({
|
||||||
'name': name,
|
'name': name,
|
||||||
'repository': repo,
|
'repository': repo,
|
||||||
'ssh_key': ssh_key,
|
'ssh_key': ssh_key,
|
||||||
'ssh_key_path': ssh_key,
|
'ssh_key_path': ssh_key,
|
||||||
|
'ssh_port': ssh_port,
|
||||||
'encrypt_mode': encrypt_mode,
|
'encrypt_mode': encrypt_mode,
|
||||||
'has_passphrase': has_passphrase,
|
'has_passphrase': has_passphrase,
|
||||||
'jobs_using': _jobs_using_borg(repo),
|
'jobs_using': _jobs_using_borg(repo),
|
||||||
@@ -16649,15 +16667,21 @@ def _capacity_local(path: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '') -> dict:
|
def _capacity_borg_ssh(host: str, user: str, remote_path: str, key_path: str = '',
|
||||||
|
port: int = 22) -> dict:
|
||||||
"""Run `df -B1 --output=size,used,avail` over ssh against the
|
"""Run `df -B1 --output=size,used,avail` over ssh against the
|
||||||
remote borg repo path. Times out fast — failure is just rendered
|
remote borg repo path. Times out fast — failure is just rendered
|
||||||
as a missing capacity badge in the UI, not a hard error."""
|
as a missing capacity badge in the UI, not a hard error.
|
||||||
|
|
||||||
|
``port`` defaults to 22 (standard SSH); pass a different value for
|
||||||
|
NAS-style hosts that expose SSH on a custom port."""
|
||||||
if not host or not user or not remote_path:
|
if not host or not user or not remote_path:
|
||||||
return {'error': 'incomplete ssh target'}
|
return {'error': 'incomplete ssh target'}
|
||||||
ssh_target = f'{user}@{host}'
|
ssh_target = f'{user}@{host}'
|
||||||
cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
|
cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5',
|
||||||
'-o', 'StrictHostKeyChecking=accept-new']
|
'-o', 'StrictHostKeyChecking=accept-new']
|
||||||
|
if port and port != 22:
|
||||||
|
cmd += ['-p', str(int(port))]
|
||||||
if key_path:
|
if key_path:
|
||||||
cmd += ['-i', key_path]
|
cmd += ['-i', key_path]
|
||||||
cmd += [ssh_target, f'df -B1 --output=size,used,avail {shlex.quote(remote_path)}']
|
cmd += [ssh_target, f'df -B1 --output=size,used,avail {shlex.quote(remote_path)}']
|
||||||
@@ -18017,11 +18041,17 @@ def api_host_backups_dest_capacity():
|
|||||||
if kind == 'local' or kind == 'borg-local':
|
if kind == 'local' or kind == 'borg-local':
|
||||||
cap = _capacity_local((t.get('path') or '').strip())
|
cap = _capacity_local((t.get('path') or '').strip())
|
||||||
elif kind == 'borg-ssh':
|
elif kind == 'borg-ssh':
|
||||||
|
port_raw = t.get('port')
|
||||||
|
try:
|
||||||
|
port = int(port_raw) if port_raw not in (None, '', 0, '0') else 22
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = 22
|
||||||
cap = _capacity_borg_ssh(
|
cap = _capacity_borg_ssh(
|
||||||
(t.get('host') or '').strip(),
|
(t.get('host') or '').strip(),
|
||||||
(t.get('user') or '').strip(),
|
(t.get('user') or '').strip(),
|
||||||
(t.get('remote_path') or '').strip(),
|
(t.get('remote_path') or '').strip(),
|
||||||
(t.get('key_path') or '').strip(),
|
(t.get('key_path') or '').strip(),
|
||||||
|
port=port,
|
||||||
)
|
)
|
||||||
elif kind == 'pbs':
|
elif kind == 'pbs':
|
||||||
cap = _capacity_pbs(
|
cap = _capacity_pbs(
|
||||||
@@ -19219,7 +19249,23 @@ def api_host_backups_dest_borg_add():
|
|||||||
rpath = (payload.get('ssh_remote_path') or '').strip().lstrip('/')
|
rpath = (payload.get('ssh_remote_path') or '').strip().lstrip('/')
|
||||||
if not user or not host or not rpath:
|
if not user or not host or not rpath:
|
||||||
return jsonify({'error': 'ssh_user, ssh_host and ssh_remote_path are required for ssh mode'}), 400
|
return jsonify({'error': 'ssh_user, ssh_host and ssh_remote_path are required for ssh mode'}), 400
|
||||||
repo = f'ssh://{user}@{host}/{rpath}'
|
# Optional custom SSH port — NAS-style hosts often expose SSH on
|
||||||
|
# a non-standard port to reduce noise from bots. Empty / missing
|
||||||
|
# means default 22, which we leave out of the URL so existing
|
||||||
|
# targets stay byte-identical to how the shell installer writes them.
|
||||||
|
raw_port = payload.get('ssh_port')
|
||||||
|
ssh_port = 22
|
||||||
|
if raw_port not in (None, '', 0, '0'):
|
||||||
|
try:
|
||||||
|
ssh_port = int(raw_port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
|
||||||
|
if not (1 <= ssh_port <= 65535):
|
||||||
|
return jsonify({'error': 'ssh_port must be an integer between 1 and 65535'}), 400
|
||||||
|
if ssh_port == 22:
|
||||||
|
repo = f'ssh://{user}@{host}/{rpath}'
|
||||||
|
else:
|
||||||
|
repo = f'ssh://{user}@{host}:{ssh_port}/{rpath}'
|
||||||
ssh_key = (payload.get('ssh_key_path') or '').strip()
|
ssh_key = (payload.get('ssh_key_path') or '').strip()
|
||||||
elif mode == 'local':
|
elif mode == 'local':
|
||||||
repo = (payload.get('repo') or '').strip()
|
repo = (payload.get('repo') or '').strip()
|
||||||
|
|||||||
@@ -2163,6 +2163,12 @@ hb_borg_generate_and_install_key() {
|
|||||||
local borg_user="$1" host="$2" rpath="$3" mode="$4"
|
local borg_user="$1" host="$2" rpath="$3" mode="$4"
|
||||||
local _out_var="$5"
|
local _out_var="$5"
|
||||||
local -n _out_ref="$_out_var"
|
local -n _out_ref="$_out_var"
|
||||||
|
# Custom SSH port arrives via env var so the callers (this file
|
||||||
|
# itself, in 7+ places) don't have to change their signature. When
|
||||||
|
# unset or 22, ssh uses its default and no `-p` flag is injected.
|
||||||
|
local _port="${HB_BORG_INSTALL_PORT:-22}"
|
||||||
|
local _p_flag=()
|
||||||
|
[[ "$_port" != "22" ]] && _p_flag=(-p "$_port")
|
||||||
|
|
||||||
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
|
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
|
||||||
local pub_file="${key_file}.pub"
|
local pub_file="${key_file}.pub"
|
||||||
@@ -2346,6 +2352,7 @@ hb_borg_generate_and_install_key() {
|
|||||||
-o StrictHostKeyChecking=accept-new \
|
-o StrictHostKeyChecking=accept-new \
|
||||||
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
||||||
-o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \
|
-o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \
|
||||||
|
"${_p_flag[@]}" \
|
||||||
"$admin_user@$host" "true" 2>&1) || true
|
"$admin_user@$host" "true" 2>&1) || true
|
||||||
if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then
|
if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then
|
||||||
# SSH password auth refused by the server — common when the Borg
|
# SSH password auth refused by the server — common when the Borg
|
||||||
@@ -2402,6 +2409,7 @@ hb_borg_generate_and_install_key() {
|
|||||||
local push_rc
|
local push_rc
|
||||||
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
|
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
|
||||||
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
|
||||||
|
"${_p_flag[@]}" \
|
||||||
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
|
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
|
||||||
push_rc=$?
|
push_rc=$?
|
||||||
|
|
||||||
@@ -2509,6 +2517,22 @@ hb_configure_borg_manual() {
|
|||||||
12 78 "borg" 3>&1 1>&2 2>&3) || return 1
|
12 78 "borg" 3>&1 1>&2 2>&3) || return 1
|
||||||
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
|
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
|
||||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||||
|
# Custom SSH port (defaults to 22). NAS-style hosts often
|
||||||
|
# move SSH off 22 to keep their intrusion warnings quiet.
|
||||||
|
# Accepted range: 1-65535; anything else re-prompts.
|
||||||
|
local port
|
||||||
|
while :; do
|
||||||
|
port=$(dialog --backtitle "ProxMenux" \
|
||||||
|
--inputbox "$(hb_translate "SSH port (default 22):")" \
|
||||||
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "22" 3>&1 1>&2 2>&3) || return 1
|
||||||
|
port="${port//[[:space:]]/}"
|
||||||
|
[[ -z "$port" ]] && port=22
|
||||||
|
if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
dialog --backtitle "ProxMenux" \
|
||||||
|
--msgbox "$(hb_translate "Port must be a number between 1 and 65535.")" 8 60
|
||||||
|
done
|
||||||
rpath=$(dialog --backtitle "ProxMenux" \
|
rpath=$(dialog --backtitle "ProxMenux" \
|
||||||
--inputbox "$(hb_translate "Remote repository path:")" \
|
--inputbox "$(hb_translate "Remote repository path:")" \
|
||||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||||
@@ -2598,7 +2622,12 @@ hb_configure_borg_manual() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
generate-auto|generate-manual|generate-pct)
|
generate-auto|generate-manual|generate-pct)
|
||||||
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
|
# Thread the custom port to the key-install helper
|
||||||
|
# via env var so the sshpass probe and the actual
|
||||||
|
# push both hit the right port on the Borg server.
|
||||||
|
HB_BORG_INSTALL_PORT="${port:-22}" \
|
||||||
|
hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key
|
||||||
|
if (( $? != 0 )); then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
@@ -2606,7 +2635,15 @@ hb_configure_borg_manual() {
|
|||||||
ssh_key=""
|
ssh_key=""
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
repo="ssh://$user@$host/$rpath"
|
# Custom SSH port is embedded in the URL — Borg's ssh://
|
||||||
|
# scheme natively supports `ssh://user@host:port/path`. Port
|
||||||
|
# 22 is left out for backwards compatibility with existing
|
||||||
|
# borg-targets.txt entries that never carried the port.
|
||||||
|
if [[ "${port:-22}" == "22" ]]; then
|
||||||
|
repo="ssh://$user@$host/$rpath"
|
||||||
|
else
|
||||||
|
repo="ssh://$user@$host:$port/$rpath"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -2635,7 +2672,13 @@ hb_configure_borg_manual() {
|
|||||||
|
|
||||||
_borg_repo_ref_new="$repo"
|
_borg_repo_ref_new="$repo"
|
||||||
if [[ -n "$ssh_key" ]]; then
|
if [[ -n "$ssh_key" ]]; then
|
||||||
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
local rsh_cmd="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
||||||
|
[[ -n "${port:-}" && "$port" != "22" ]] && rsh_cmd="$rsh_cmd -p $port"
|
||||||
|
export BORG_RSH="$rsh_cmd"
|
||||||
|
elif [[ -n "${port:-}" && "$port" != "22" ]]; then
|
||||||
|
# No custom key but non-default port — still need to tell ssh
|
||||||
|
# which port to hit so `borg` doesn't fall back to 22.
|
||||||
|
export BORG_RSH="ssh -o StrictHostKeyChecking=accept-new -p $port"
|
||||||
else
|
else
|
||||||
unset BORG_RSH
|
unset BORG_RSH
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user