diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 1953ef99..2e455cd5 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -2717,6 +2717,11 @@ interface BorgRepo { name: string repository: 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; // older deployments without the fields default to "repokey" (the // shell installer's historical default) and unknown-passphrase. @@ -6092,6 +6097,11 @@ function AddDestinationDialog({ const [borgMode, setBorgMode] = useState<"local" | "ssh">("local") const [borgSshUser, setBorgSshUser] = useState("borg") 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 [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg") const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null) @@ -6148,26 +6158,32 @@ function AddDestinationDialog({ setUsername(editing.username || "root@pam") setFingerprint(editing.fingerprint || "") setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg") - setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") setBorgEncryptionEnabled(true); setLocalPath("") return } if (editing && editing.kind === "borg") { 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) if (ssh) { setBorgMode("ssh") setBorgSshUser(ssh[1]) 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") setBorgRepo("") } else { setBorgMode("local") setBorgRepo(repo) - setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") } const mode = editing.encrypt_mode || "repokey" @@ -6186,6 +6202,7 @@ function AddDestinationDialog({ setBorgMode("local") setBorgSshUser("borg") setBorgSshHost("") + setBorgSshPort("22") setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") setBorgEncryptionEnabled(true) @@ -6274,6 +6291,12 @@ function AddDestinationDialog({ body.ssh_host = borgSshHost.trim() body.ssh_remote_path = borgSshRemotePath.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", { method: "POST", @@ -6409,9 +6432,23 @@ function AddDestinationDialog({ {t("backup.destinations.sshUserHelpBefore")} borg serve. {t("backup.destinations.sshUserHelpAfter")} borg, {t("backup.destinations.not")} root.

-
- - setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" /> +
+
+ + setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" /> +
+
+ + setBorgSshPort(e.target.value.replace(/[^\d]/g, ""))} + className="font-mono mt-1" + placeholder="22" + inputMode="numeric" + maxLength={5} + /> +
diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 20bef7fa..21d8cacb 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -355,6 +355,15 @@ function suggestPackageName(name: string) { .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) { const t = useT() 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) // Editor state 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 [testingDetector, setTestingDetector] = useState(false) const [detectorTest, setDetectorTest] = useState(null) @@ -742,6 +753,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop setShowAdvanced(false) } } + setBinaryArgsInput((seed.binary_args || []).join(", ")) + setCommandArgvInput((seed.command_argv || []).join(", ")) setEditing({ appId: existing?.id || null, draft: seed }) setDetectorTest(null) setError(null) @@ -749,6 +762,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop const closeEditor = () => { setEditing(null) + setBinaryArgsInput("") + setCommandArgvInput("") setDetectorTest(null) setError(null) } @@ -771,6 +786,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop setSidecar(r) setLxcAppsCached(vmid, r, suggestions) setEditing(null) + setBinaryArgsInput("") + setCommandArgvInput("") onChange?.() } catch (e: any) { 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.container_name = t.container_name || "" patch.label = t.label || "" + patch.command_argv = t.command_argv || [] patch.installed_regex = t.installed_regex || "" patch.upstream_type = (t as any).upstream_type || (t.repo ? "github" : "") 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.docker_image = (t as any).docker_image || "" patch.tag_regex = t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)" + setBinaryArgsInput((t.binary_args || []).join(", ")) + setCommandArgvInput((t.command_argv || []).join(", ")) setShowAdvanced(true) } setEditing((prev) => prev ? { ...prev, draft: { ...prev.draft, ...patch } } : prev) @@ -1713,10 +1733,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop setField({ - binary_args: e.target.value.split(",").map(s => s.trim()).filter(Boolean), - })} + value={binaryArgsInput} + onChange={(e) => { + const value = e.target.value + setBinaryArgsInput(value) + setField({ binary_args: parseArgvInput(value) }) + }} placeholder={t("vmLxc.appEditor.binaryArgsPlaceholder")} className="font-mono text-xs" /> @@ -1732,10 +1754,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop setField({ - command_argv: e.target.value.split(",").map(s => s.trim()).filter(Boolean), - })} + value={commandArgvInput} + onChange={(e) => { + const value = e.target.value + setCommandArgvInput(value) + setField({ command_argv: parseArgvInput(value) }) + }} placeholder={t("vmLxc.appEditor.commandPlaceholder")} className="font-mono text-xs" /> diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 7264b8e8..ed595107 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VMs/LXCs", "when": "Wann", "whenLabel": "Wann", - "zfsPools": "ZFS-Pools" + "zfsPools": "ZFS-Pools", + "sshPort": "SSH-Port" }, "jobs": { "attachDescriptionAfter": "Backups.", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index d4b39f2f..b12ac54e 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VMs/LXCs", "when": "When", "whenLabel": "When", - "zfsPools": "ZFS pools" + "zfsPools": "ZFS pools", + "sshPort": "SSH port" }, "jobs": { "attachDescriptionAfter": "backups.", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 3f738abd..9a4b27d8 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VM/LXC", "when": "Cuando", "whenLabel": "Cuando", - "zfsPools": "grupos ZFS" + "zfsPools": "grupos ZFS", + "sshPort": "Puerto SSH" }, "jobs": { "attachDescriptionAfter": "copias de seguridad.", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 2857230b..003a67ae 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VM/LXC", "when": "Quand", "whenLabel": "Quand", - "zfsPools": "Pools ZFS" + "zfsPools": "Pools ZFS", + "sshPort": "Port SSH" }, "jobs": { "attachDescriptionAfter": "sauvegardes.", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 8a4f7ef5..31350dd1 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VM/LXC", "when": "Quando", "whenLabel": "Quando", - "zfsPools": "Pool ZFS" + "zfsPools": "Pool ZFS", + "sshPort": "Porta SSH" }, "jobs": { "attachDescriptionAfter": "backup.", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index b031fad8..075c2925 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VMs/LXCs", "when": "Quando", "whenLabel": "Quando", - "zfsPools": "Conjuntos ZFS" + "zfsPools": "Conjuntos ZFS", + "sshPort": "Porta SSH" }, "jobs": { "attachDescriptionAfter": "cópias de segurança.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index d562897c..02896f07 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -3962,7 +3962,8 @@ "vmsLxcs": "VM/LXC", "when": "Kedy", "whenLabel": "Kedy", - "zfsPools": "ZFS pooly" + "zfsPools": "ZFS pooly", + "sshPort": "SSH port" }, "jobs": { "attachDescriptionAfter": "zálohy.", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 3b7beada..0d3c6e25 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -3963,7 +3963,8 @@ "vmsLxcs": "virtuella datorer/LXC:er", "when": "När", "whenLabel": "När", - "zfsPools": "ZFS pooler" + "zfsPools": "ZFS pooler", + "sshPort": "SSH-port" }, "jobs": { "attachDescriptionAfter": "säkerhetskopior.", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 70712a8a..944e505f 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -16380,11 +16380,29 @@ def _list_borg_destinations() -> list: encrypt_mode = (parts[3] if len(parts) > 3 else '').strip() or 'repokey' pass_file = f'{_BACKUP_STATE_DIR}/borg-pass-{name}.txt' 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({ 'name': name, 'repository': repo, 'ssh_key': ssh_key, 'ssh_key_path': ssh_key, + 'ssh_port': ssh_port, 'encrypt_mode': encrypt_mode, 'has_passphrase': has_passphrase, '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 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: return {'error': 'incomplete ssh target'} ssh_target = f'{user}@{host}' cmd = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new'] + if port and port != 22: + cmd += ['-p', str(int(port))] if key_path: cmd += ['-i', key_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': cap = _capacity_local((t.get('path') or '').strip()) 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( (t.get('host') or '').strip(), (t.get('user') or '').strip(), (t.get('remote_path') or '').strip(), (t.get('key_path') or '').strip(), + port=port, ) elif kind == 'pbs': cap = _capacity_pbs( @@ -19219,7 +19249,23 @@ def api_host_backups_dest_borg_add(): rpath = (payload.get('ssh_remote_path') or '').strip().lstrip('/') 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 - 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() elif mode == 'local': repo = (payload.get('repo') or '').strip() diff --git a/scripts/backup_restore/lib_host_backup_common.sh b/scripts/backup_restore/lib_host_backup_common.sh index 3d71a7b7..1b90ac2e 100755 --- a/scripts/backup_restore/lib_host_backup_common.sh +++ b/scripts/backup_restore/lib_host_backup_common.sh @@ -2163,6 +2163,12 @@ hb_borg_generate_and_install_key() { local borg_user="$1" host="$2" rpath="$3" mode="$4" local _out_var="$5" 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 pub_file="${key_file}.pub" @@ -2346,6 +2352,7 @@ hb_borg_generate_and_install_key() { -o StrictHostKeyChecking=accept-new \ -o PreferredAuthentications=password -o PubkeyAuthentication=no \ -o NumberOfPasswordPrompts=1 -o ConnectTimeout=10 \ + "${_p_flag[@]}" \ "$admin_user@$host" "true" 2>&1) || true if echo "$_probe" | grep -qiE "permission denied[[:space:]]*\(publickey"; then # SSH password auth refused by the server — common when the Borg @@ -2402,6 +2409,7 @@ hb_borg_generate_and_install_key() { local push_rc SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \ -o PreferredAuthentications=password -o PubkeyAuthentication=no \ + "${_p_flag[@]}" \ "$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1 push_rc=$? @@ -2509,6 +2517,22 @@ hb_configure_borg_manual() { 12 78 "borg" 3>&1 1>&2 2>&3) || return 1 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 + # 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" \ --inputbox "$(hb_translate "Remote repository path:")" \ "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \ @@ -2598,7 +2622,12 @@ hb_configure_borg_manual() { fi ;; 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 fi ;; @@ -2606,7 +2635,15 @@ hb_configure_borg_manual() { ssh_key="" ;; 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 @@ -2635,7 +2672,13 @@ hb_configure_borg_manual() { _borg_repo_ref_new="$repo" 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 unset BORG_RSH fi