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:
MacRimi
2026-09-02 14:43:28 +02:00
parent 45cc2ba36e
commit 91b4200179
12 changed files with 187 additions and 29 deletions
+41 -4
View File
@@ -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,10 +6432,24 @@ 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>.
</p>
</div>
<div className="grid grid-cols-[1fr_100px] gap-3">
<div>
<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>
<Label htmlFor="borgSshPath">{t("backup.fields.remoteRepositoryPath")}</Label>
<Input id="borgSshPath" value={borgSshRemotePath} onChange={(e) => setBorgSshRemotePath(e.target.value)} className="font-mono mt-1" placeholder="/backup/borgbackup" />
+32 -8
View File
@@ -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<DetectorTestResult | null>(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
<Label htmlFor="app-de-args">{t("vmLxc.appEditor.binaryArgsLabel")}</Label>
<Input
id="app-de-args"
value={(draft.binary_args || []).join(", ")}
onChange={(e) => 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
<Label htmlFor="app-cmd-argv">{t("vmLxc.appEditor.commandLabel")}</Label>
<Input
id="app-cmd-argv"
value={(draft.command_argv || []).join(", ")}
onChange={(e) => 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"
/>
+2 -1
View File
@@ -3962,7 +3962,8 @@
"vmsLxcs": "VMs/LXCs",
"when": "Wann",
"whenLabel": "Wann",
"zfsPools": "ZFS-Pools"
"zfsPools": "ZFS-Pools",
"sshPort": "SSH-Port"
},
"jobs": {
"attachDescriptionAfter": "Backups.",
+2 -1
View File
@@ -3962,7 +3962,8 @@
"vmsLxcs": "VMs/LXCs",
"when": "When",
"whenLabel": "When",
"zfsPools": "ZFS pools"
"zfsPools": "ZFS pools",
"sshPort": "SSH port"
},
"jobs": {
"attachDescriptionAfter": "backups.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -3962,7 +3962,8 @@
"vmsLxcs": "VM/LXC",
"when": "Quand",
"whenLabel": "Quand",
"zfsPools": "Pools ZFS"
"zfsPools": "Pools ZFS",
"sshPort": "Port SSH"
},
"jobs": {
"attachDescriptionAfter": "sauvegardes.",
+2 -1
View File
@@ -3962,7 +3962,8 @@
"vmsLxcs": "VM/LXC",
"when": "Quando",
"whenLabel": "Quando",
"zfsPools": "Pool ZFS"
"zfsPools": "Pool ZFS",
"sshPort": "Porta SSH"
},
"jobs": {
"attachDescriptionAfter": "backup.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -3962,7 +3962,8 @@
"vmsLxcs": "VM/LXC",
"when": "Kedy",
"whenLabel": "Kedy",
"zfsPools": "ZFS pooly"
"zfsPools": "ZFS pooly",
"sshPort": "SSH port"
},
"jobs": {
"attachDescriptionAfter": "zálohy.",
+2 -1
View File
@@ -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.",
+48 -2
View File
@@ -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
# 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()
@@ -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
# 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