feat: ✨ one time credentials for hosts
This commit is contained in:
@@ -111,11 +111,14 @@ def init_db():
|
|||||||
label VARCHAR(255) NOT NULL,
|
label VARCHAR(255) NOT NULL,
|
||||||
hostname VARCHAR(512) NOT NULL,
|
hostname VARCHAR(512) NOT NULL,
|
||||||
port INT NOT NULL DEFAULT 22,
|
port INT NOT NULL DEFAULT 22,
|
||||||
identity_id INT NOT NULL,
|
identity_id INT,
|
||||||
|
inline_identity_auth_type ENUM('password','publickey') NULL,
|
||||||
|
inline_identity_encrypted_blob TEXT NULL,
|
||||||
|
inline_identity_encrypted_key_passphrase TEXT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id)
|
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id)
|
||||||
REFERENCES ssh_identities(id) ON DELETE RESTRICT,
|
REFERENCES ssh_identities(id) ON DELETE SET NULL,
|
||||||
CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
|
CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
|
||||||
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -139,6 +142,7 @@ def init_db():
|
|||||||
if s:
|
if s:
|
||||||
cur.execute(s)
|
cur.execute(s)
|
||||||
_ensure_jump_host_schema(cur)
|
_ensure_jump_host_schema(cur)
|
||||||
|
_ensure_inline_identity_schema(cur)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_jump_host_schema(cur) -> None:
|
def _ensure_jump_host_schema(cur) -> None:
|
||||||
@@ -164,6 +168,57 @@ def _ensure_jump_host_schema(cur) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_inline_identity_schema(cur) -> None:
|
||||||
|
"""Migrate existing databases to support inline (one-time) credentials."""
|
||||||
|
# Check and add inline_identity_auth_type column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_auth_type'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_auth_type ENUM('password','publickey') NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and add inline_identity_encrypted_blob column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_encrypted_blob'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_blob TEXT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and add inline_identity_encrypted_key_passphrase column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_encrypted_key_passphrase'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_key_passphrase TEXT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _like_escape(s: str) -> str:
|
def _like_escape(s: str) -> str:
|
||||||
return (
|
return (
|
||||||
s.replace("\\", "\\\\")
|
s.replace("\\", "\\\\")
|
||||||
@@ -370,7 +425,21 @@ def _registry_count() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]:
|
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]:
|
||||||
payload = decrypt_secret(host_row["encrypted_blob"])
|
# Prefer inline identity if available, otherwise use saved identity
|
||||||
|
if host_row.get("inline_identity_encrypted_blob"):
|
||||||
|
# Use inline credentials
|
||||||
|
auth_type = host_row["inline_identity_auth_type"]
|
||||||
|
encrypted_blob = host_row["inline_identity_encrypted_blob"]
|
||||||
|
encrypted_key_passphrase = host_row.get("inline_identity_encrypted_key_passphrase")
|
||||||
|
else:
|
||||||
|
# Use saved identity
|
||||||
|
if not host_row.get("encrypted_blob"):
|
||||||
|
raise ValueError("host has no identity configured")
|
||||||
|
auth_type = host_row["auth_type"]
|
||||||
|
encrypted_blob = host_row["encrypted_blob"]
|
||||||
|
encrypted_key_passphrase = host_row.get("encrypted_key_passphrase")
|
||||||
|
|
||||||
|
payload = decrypt_secret(encrypted_blob)
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
@@ -380,7 +449,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
|||||||
if not username_ssh:
|
if not username_ssh:
|
||||||
raise ValueError("identity payload missing ssh_username")
|
raise ValueError("identity payload missing ssh_username")
|
||||||
|
|
||||||
if host_row["auth_type"] == "password":
|
if auth_type == "password":
|
||||||
pwd = data.get("password")
|
pwd = data.get("password")
|
||||||
if not pwd:
|
if not pwd:
|
||||||
raise ValueError("missing password in identity")
|
raise ValueError("missing password in identity")
|
||||||
@@ -400,8 +469,8 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
|||||||
raise ValueError("missing private_key in identity")
|
raise ValueError("missing private_key in identity")
|
||||||
pkey = None
|
pkey = None
|
||||||
key_pass = None
|
key_pass = None
|
||||||
if host_row.get("encrypted_key_passphrase"):
|
if encrypted_key_passphrase:
|
||||||
key_pass = decrypt_secret(host_row["encrypted_key_passphrase"]) or None
|
key_pass = decrypt_secret(encrypted_key_passphrase) or None
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
key_classes: list[type] = [
|
key_classes: list[type] = [
|
||||||
paramiko.RSAKey,
|
paramiko.RSAKey,
|
||||||
@@ -446,9 +515,10 @@ def _load_host_connect_row(cur, host_id: int) -> dict[str, Any] | None:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||||
|
h.inline_identity_auth_type, h.inline_identity_encrypted_blob, h.inline_identity_encrypted_key_passphrase,
|
||||||
i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
|
i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
|
||||||
FROM ssh_hosts h
|
FROM ssh_hosts h
|
||||||
JOIN ssh_identities i ON i.id = h.identity_id
|
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||||
WHERE h.id = %s
|
WHERE h.id = %s
|
||||||
""",
|
""",
|
||||||
(host_id,),
|
(host_id,),
|
||||||
@@ -720,11 +790,12 @@ def _host_select_sql(extra_where: str = "") -> str:
|
|||||||
return f"""
|
return f"""
|
||||||
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||||
h.created_at, h.updated_at,
|
h.created_at, h.updated_at,
|
||||||
i.label AS identity_label, i.auth_type AS identity_auth_type,
|
COALESCE(i.label, 'One-time') AS identity_label,
|
||||||
|
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
|
||||||
pf.label AS folder_label,
|
pf.label AS folder_label,
|
||||||
jh.label AS jump_host_label
|
jh.label AS jump_host_label
|
||||||
FROM ssh_hosts h
|
FROM ssh_hosts h
|
||||||
JOIN ssh_identities i ON i.id = h.identity_id
|
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||||
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
||||||
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
|
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
|
||||||
{extra_where}
|
{extra_where}
|
||||||
@@ -917,11 +988,47 @@ def create_host():
|
|||||||
label = (body.get("label") or "").strip()
|
label = (body.get("label") or "").strip()
|
||||||
hostname = (body.get("hostname") or "").strip()
|
hostname = (body.get("hostname") or "").strip()
|
||||||
port = int(body.get("port") or 22)
|
port = int(body.get("port") or 22)
|
||||||
|
use_inline = body.get("use_inline_identity", False)
|
||||||
identity_id = body.get("identity_id")
|
identity_id = body.get("identity_id")
|
||||||
jump_host_raw = body.get("jump_host_id")
|
jump_host_raw = body.get("jump_host_id")
|
||||||
jump_host_id = int(jump_host_raw) if jump_host_raw is not None and jump_host_raw != "" else None
|
jump_host_id = int(jump_host_raw) if jump_host_raw is not None and jump_host_raw != "" else None
|
||||||
if not label or not hostname or not identity_id:
|
|
||||||
return jsonify({"error": "label, hostname, identity_id required"}), 400
|
if not label or not hostname:
|
||||||
|
return jsonify({"error": "label, hostname required"}), 400
|
||||||
|
|
||||||
|
# Validate identity or inline credentials
|
||||||
|
inline_auth_type = None
|
||||||
|
inline_blob = None
|
||||||
|
inline_key_pass = None
|
||||||
|
|
||||||
|
if use_inline:
|
||||||
|
# Use inline credentials
|
||||||
|
auth_type = body.get("auth_type")
|
||||||
|
ssh_username = (body.get("ssh_username") or "").strip()
|
||||||
|
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||||
|
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||||
|
|
||||||
|
if auth_type == "password":
|
||||||
|
password = body.get("password")
|
||||||
|
if not password:
|
||||||
|
return jsonify({"error": "password required"}), 400
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||||
|
else:
|
||||||
|
private_key = body.get("private_key")
|
||||||
|
if not private_key or not isinstance(private_key, str):
|
||||||
|
return jsonify({"error": "private_key required"}), 400
|
||||||
|
key_pass_plain = body.get("key_passphrase")
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||||
|
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||||
|
|
||||||
|
inline_auth_type = auth_type
|
||||||
|
inline_blob = encrypt_secret(payload)
|
||||||
|
identity_id = None
|
||||||
|
else:
|
||||||
|
# Use saved identity
|
||||||
|
if not identity_id:
|
||||||
|
return jsonify({"error": "identity_id required when not using inline identity"}), 400
|
||||||
|
|
||||||
fid = body.get("folder_id")
|
fid = body.get("folder_id")
|
||||||
folder_id = int(fid) if fid is not None and fid != "" else None
|
folder_id = int(fid) if fid is not None and fid != "" else None
|
||||||
if folder_id is not None:
|
if folder_id is not None:
|
||||||
@@ -929,17 +1036,24 @@ def create_host():
|
|||||||
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
return jsonify({"error": "folder not found"}), 400
|
return jsonify({"error": "folder not found"}), 400
|
||||||
|
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
if jump_host_id is not None:
|
if jump_host_id is not None:
|
||||||
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
return jsonify({"error": "jump host not found"}), 400
|
return jsonify({"error": "jump host not found"}), 400
|
||||||
|
|
||||||
|
if identity_id:
|
||||||
|
identity_id = int(identity_id)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id)
|
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
inline_identity_auth_type, inline_identity_encrypted_blob, inline_identity_encrypted_key_passphrase)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(folder_id, label, hostname, port, int(identity_id), jump_host_id),
|
(folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||||
|
inline_auth_type, inline_blob, inline_key_pass),
|
||||||
)
|
)
|
||||||
hid = cur.lastrowid
|
hid = cur.lastrowid
|
||||||
return jsonify({"id": hid}), 201
|
return jsonify({"id": hid}), 201
|
||||||
@@ -951,6 +1065,53 @@ def update_host(hid: int):
|
|||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
fields = []
|
fields = []
|
||||||
args: list[Any] = []
|
args: list[Any] = []
|
||||||
|
|
||||||
|
# Handle inline identity switching
|
||||||
|
if "use_inline_identity" in body:
|
||||||
|
use_inline = body.get("use_inline_identity", False)
|
||||||
|
if use_inline:
|
||||||
|
# Switch to inline credentials
|
||||||
|
auth_type = body.get("auth_type")
|
||||||
|
ssh_username = (body.get("ssh_username") or "").strip()
|
||||||
|
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||||
|
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||||
|
|
||||||
|
if auth_type == "password":
|
||||||
|
password = body.get("password")
|
||||||
|
if not password:
|
||||||
|
return jsonify({"error": "password required"}), 400
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||||
|
else:
|
||||||
|
private_key = body.get("private_key")
|
||||||
|
if not private_key or not isinstance(private_key, str):
|
||||||
|
return jsonify({"error": "private_key required"}), 400
|
||||||
|
key_pass_plain = body.get("key_passphrase")
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||||
|
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||||
|
|
||||||
|
# Clear identity_id and set inline fields
|
||||||
|
fields.append("identity_id = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_auth_type = %s")
|
||||||
|
args.append(auth_type)
|
||||||
|
fields.append("inline_identity_encrypted_blob = %s")
|
||||||
|
args.append(encrypt_secret(payload))
|
||||||
|
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||||
|
if auth_type == "password":
|
||||||
|
args.append(None)
|
||||||
|
else:
|
||||||
|
args.append(inline_key_pass)
|
||||||
|
else:
|
||||||
|
# Switch to saved identity - clear inline fields
|
||||||
|
fields.append("identity_id = %s")
|
||||||
|
args.append(body.get("identity_id"))
|
||||||
|
fields.append("inline_identity_auth_type = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_encrypted_blob = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||||
|
args.append(None)
|
||||||
|
|
||||||
if "label" in body:
|
if "label" in body:
|
||||||
fields.append("label = %s")
|
fields.append("label = %s")
|
||||||
args.append(str(body["label"]).strip())
|
args.append(str(body["label"]).strip())
|
||||||
@@ -960,7 +1121,7 @@ def update_host(hid: int):
|
|||||||
if "port" in body:
|
if "port" in body:
|
||||||
fields.append("port = %s")
|
fields.append("port = %s")
|
||||||
args.append(int(body["port"]))
|
args.append(int(body["port"]))
|
||||||
if "identity_id" in body:
|
if "identity_id" in body and "use_inline_identity" not in body:
|
||||||
fields.append("identity_id = %s")
|
fields.append("identity_id = %s")
|
||||||
args.append(int(body["identity_id"]))
|
args.append(int(body["identity_id"]))
|
||||||
if "jump_host_id" in body:
|
if "jump_host_id" in body:
|
||||||
|
|||||||
+203
-10
@@ -69,7 +69,13 @@ const hostForm = ref({
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: 0 as number,
|
identity_id: 0 as number,
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
jump_host_id: null as number | null,
|
jump_host_id: null as number | null,
|
||||||
});
|
});
|
||||||
const editHostForm = ref({
|
const editHostForm = ref({
|
||||||
@@ -77,7 +83,13 @@ const editHostForm = ref({
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: 0,
|
identity_id: 0,
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
folder_id: null as number | null,
|
folder_id: null as number | null,
|
||||||
jump_host_id: null as number | null,
|
jump_host_id: null as number | null,
|
||||||
});
|
});
|
||||||
@@ -283,20 +295,43 @@ async function submitEditIdentity() {
|
|||||||
|
|
||||||
async function submitHost() {
|
async function submitHost() {
|
||||||
const f = hostForm.value;
|
const f = hostForm.value;
|
||||||
await api.createHost({
|
const body: Record<string, unknown> = {
|
||||||
label: f.label.trim(),
|
label: f.label.trim(),
|
||||||
hostname: f.hostname.trim(),
|
hostname: f.hostname.trim(),
|
||||||
port: Number(f.port) || 22,
|
port: Number(f.port) || 22,
|
||||||
identity_id: f.identity_id,
|
|
||||||
folder_id: currentFolderId.value,
|
folder_id: currentFolderId.value,
|
||||||
jump_host_id: f.jump_host_id,
|
jump_host_id: f.jump_host_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (f.use_inline_identity) {
|
||||||
|
body.use_inline_identity = true;
|
||||||
|
body.auth_type = f.auth_type;
|
||||||
|
body.ssh_username = f.ssh_username.trim();
|
||||||
|
if (f.auth_type === "password") {
|
||||||
|
body.password = f.password;
|
||||||
|
} else {
|
||||||
|
body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase) {
|
||||||
|
body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.identity_id = f.identity_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createHost(body);
|
||||||
showHostForm.value = false;
|
showHostForm.value = false;
|
||||||
hostForm.value = {
|
hostForm.value = {
|
||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: hostForm.value.identity_id,
|
identity_id: hostForm.value.identity_id,
|
||||||
|
auth_type: "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
jump_host_id: null,
|
jump_host_id: null,
|
||||||
};
|
};
|
||||||
await refreshData();
|
await refreshData();
|
||||||
@@ -312,12 +347,19 @@ async function submitFolder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditHost(h: HostRow) {
|
function openEditHost(h: HostRow) {
|
||||||
|
const hasInlineIdentity = h.identity_id === null;
|
||||||
editHostForm.value = {
|
editHostForm.value = {
|
||||||
id: h.id,
|
id: h.id,
|
||||||
label: h.label,
|
label: h.label,
|
||||||
hostname: h.hostname,
|
hostname: h.hostname,
|
||||||
port: h.port,
|
port: h.port,
|
||||||
identity_id: h.identity_id,
|
use_inline_identity: hasInlineIdentity,
|
||||||
|
identity_id: h.identity_id || 0,
|
||||||
|
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
folder_id: h.folder_id,
|
folder_id: h.folder_id,
|
||||||
jump_host_id: h.jump_host_id,
|
jump_host_id: h.jump_host_id,
|
||||||
};
|
};
|
||||||
@@ -326,14 +368,31 @@ function openEditHost(h: HostRow) {
|
|||||||
|
|
||||||
async function submitEditHost() {
|
async function submitEditHost() {
|
||||||
const f = editHostForm.value;
|
const f = editHostForm.value;
|
||||||
await api.patchHost(f.id, {
|
const body: Record<string, unknown> = {
|
||||||
label: f.label.trim(),
|
label: f.label.trim(),
|
||||||
hostname: f.hostname.trim(),
|
hostname: f.hostname.trim(),
|
||||||
port: Number(f.port) || 22,
|
port: Number(f.port) || 22,
|
||||||
identity_id: f.identity_id,
|
|
||||||
folder_id: f.folder_id,
|
folder_id: f.folder_id,
|
||||||
jump_host_id: f.jump_host_id,
|
jump_host_id: f.jump_host_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (f.use_inline_identity) {
|
||||||
|
body.use_inline_identity = true;
|
||||||
|
body.auth_type = f.auth_type;
|
||||||
|
body.ssh_username = f.ssh_username.trim();
|
||||||
|
if (f.auth_type === "password") {
|
||||||
|
body.password = f.password;
|
||||||
|
} else {
|
||||||
|
body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase) {
|
||||||
|
body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.identity_id = f.identity_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.patchHost(f.id, body);
|
||||||
showEditHost.value = false;
|
showEditHost.value = false;
|
||||||
allHosts.value = await api.listHosts();
|
allHosts.value = await api.listHosts();
|
||||||
await refreshBrowse();
|
await refreshBrowse();
|
||||||
@@ -927,7 +986,29 @@ async function deleteIdentityRow(id: number) {
|
|||||||
max="65535"
|
max="65535"
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||||
|
<div class="mt-1 flex gap-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="!hostForm.use_inline_identity"
|
||||||
|
@change="hostForm.use_inline_identity = false"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Saved identity</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="hostForm.use_inline_identity"
|
||||||
|
@change="hostForm.use_inline_identity = true"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">One-time</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hostForm.use_inline_identity" class="mt-3">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||||
<select
|
<select
|
||||||
v-model.number="hostForm.identity_id"
|
v-model.number="hostForm.identity_id"
|
||||||
required
|
required
|
||||||
@@ -937,6 +1018,52 @@ async function deleteIdentityRow(id: number) {
|
|||||||
{{ i.label }} ({{ i.auth_type }})
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||||
|
<select
|
||||||
|
v-model="hostForm.auth_type"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="publickey">Public key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.ssh_username"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="hostForm.auth_type === 'password'">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||||
|
<textarea
|
||||||
|
v-model="hostForm.private_key"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
<select
|
<select
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
@@ -964,7 +1091,7 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
:disabled="!identities.length"
|
:disabled="!hostForm.use_inline_identity && !identities.length"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -1057,7 +1184,29 @@ async function deleteIdentityRow(id: number) {
|
|||||||
{{ folderOptionLabel(f.id) }}
|
{{ folderOptionLabel(f.id) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||||
|
<div class="mt-1 flex gap-2">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="!editHostForm.use_inline_identity"
|
||||||
|
@change="editHostForm.use_inline_identity = false"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">Saved identity</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="editHostForm.use_inline_identity"
|
||||||
|
@change="editHostForm.use_inline_identity = true"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">One-time</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="!editHostForm.use_inline_identity" class="mt-3">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||||
<select
|
<select
|
||||||
v-model.number="editHostForm.identity_id"
|
v-model.number="editHostForm.identity_id"
|
||||||
required
|
required
|
||||||
@@ -1067,6 +1216,50 @@ async function deleteIdentityRow(id: number) {
|
|||||||
{{ i.label }} ({{ i.auth_type }})
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||||
|
<select
|
||||||
|
v-model="editHostForm.auth_type"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="publickey">Public key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.ssh_username"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="editHostForm.auth_type === 'password'">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.password"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editHostForm.private_key"
|
||||||
|
rows="4"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
<select
|
<select
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
|||||||
+2
-16
@@ -98,14 +98,7 @@ export const api = {
|
|||||||
return d.items;
|
return d.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createHost(body: {
|
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
label: string;
|
|
||||||
hostname: string;
|
|
||||||
port?: number;
|
|
||||||
identity_id: number;
|
|
||||||
folder_id?: number | null;
|
|
||||||
jump_host_id?: number | null;
|
|
||||||
}): Promise<{ id: number }> {
|
|
||||||
const res = await fetch("/api/hosts", {
|
const res = await fetch("/api/hosts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -117,14 +110,7 @@ export const api = {
|
|||||||
|
|
||||||
async patchHost(
|
async patchHost(
|
||||||
id: number,
|
id: number,
|
||||||
body: Partial<{
|
body: Record<string, unknown>,
|
||||||
label: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
identity_id: number;
|
|
||||||
folder_id: number | null;
|
|
||||||
jump_host_id: number | null;
|
|
||||||
}>,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`/api/hosts/${id}`, {
|
const res = await fetch(`/api/hosts/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
Reference in New Issue
Block a user