Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d542264567 | |||
| 7f717684eb | |||
| 90103f79c8 | |||
| 20db4742a7 | |||
| ca8b5dea7f | |||
| ca3d27e7f9 | |||
| 035f871b00 | |||
| 5bba2947c4 |
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/public/ssh:dev .
|
||||
docker push cr.jdbnet.co.uk/public/ssh:dev
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Valid Project Key
|
||||
id: sonar_setup
|
||||
run: |
|
||||
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
|
||||
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: SonarQube Scan
|
||||
uses: sonarsource/sonarqube-scan-action@master
|
||||
continue-on-error: true
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
|
||||
-Dsonar.projectName=${{ gitea.repository }}
|
||||
-Dsonar.qualitygate.wait=true
|
||||
@@ -111,11 +111,14 @@ def init_db():
|
||||
label VARCHAR(255) NOT NULL,
|
||||
hostname VARCHAR(512) NOT NULL,
|
||||
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,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
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)
|
||||
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
||||
);
|
||||
@@ -139,6 +142,7 @@ def init_db():
|
||||
if s:
|
||||
cur.execute(s)
|
||||
_ensure_jump_host_schema(cur)
|
||||
_ensure_inline_identity_schema(cur)
|
||||
|
||||
|
||||
def _ensure_jump_host_schema(cur) -> None:
|
||||
@@ -164,6 +168,73 @@ 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"
|
||||
)
|
||||
|
||||
# Check and add last_connected_at column
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ssh_hosts'
|
||||
AND COLUMN_NAME = 'last_connected_at'
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
cur.execute(
|
||||
"ALTER TABLE ssh_hosts ADD COLUMN last_connected_at TIMESTAMP NULL"
|
||||
)
|
||||
|
||||
|
||||
def _like_escape(s: str) -> str:
|
||||
return (
|
||||
s.replace("\\", "\\\\")
|
||||
@@ -370,7 +441,21 @@ def _registry_count() -> int:
|
||||
|
||||
|
||||
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)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
@@ -380,7 +465,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
||||
if not username_ssh:
|
||||
raise ValueError("identity payload missing ssh_username")
|
||||
|
||||
if host_row["auth_type"] == "password":
|
||||
if auth_type == "password":
|
||||
pwd = data.get("password")
|
||||
if not pwd:
|
||||
raise ValueError("missing password in identity")
|
||||
@@ -400,8 +485,8 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
||||
raise ValueError("missing private_key in identity")
|
||||
pkey = None
|
||||
key_pass = None
|
||||
if host_row.get("encrypted_key_passphrase"):
|
||||
key_pass = decrypt_secret(host_row["encrypted_key_passphrase"]) or None
|
||||
if encrypted_key_passphrase:
|
||||
key_pass = decrypt_secret(encrypted_key_passphrase) or None
|
||||
last_err: Exception | None = None
|
||||
key_classes: list[type] = [
|
||||
paramiko.RSAKey,
|
||||
@@ -446,9 +531,10 @@ def _load_host_connect_row(cur, host_id: int) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
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
|
||||
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
|
||||
""",
|
||||
(host_id,),
|
||||
@@ -512,7 +598,13 @@ def _insert_connection_audit(host_row: dict[str, Any]) -> int | None:
|
||||
host_row.get("jump_host_id"),
|
||||
),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
audit_id = int(cur.lastrowid)
|
||||
# Update the host's last_connected_at timestamp
|
||||
cur.execute(
|
||||
"UPDATE ssh_hosts SET last_connected_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(int(host_row["id"]),),
|
||||
)
|
||||
return audit_id
|
||||
except Exception:
|
||||
log.exception("failed to insert connection audit row")
|
||||
return None
|
||||
@@ -699,6 +791,17 @@ def update_identity(iid: int):
|
||||
@require_login
|
||||
def delete_identity(iid: int):
|
||||
with db_cursor() as (_, cur):
|
||||
# Check if identity is being used by any hosts
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as count FROM ssh_hosts WHERE identity_id = %s",
|
||||
(iid,),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if result and result["count"] > 0:
|
||||
return jsonify({
|
||||
"error": f"Cannot delete identity: it is being used by {result['count']} host(s)"
|
||||
}), 409
|
||||
|
||||
cur.execute("DELETE FROM ssh_identities WHERE id = %s", (iid,))
|
||||
if cur.rowcount == 0:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
@@ -708,12 +811,13 @@ def delete_identity(iid: int):
|
||||
def _host_select_sql(extra_where: str = "") -> str:
|
||||
return f"""
|
||||
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,
|
||||
i.label AS identity_label, i.auth_type AS identity_auth_type,
|
||||
h.created_at, h.updated_at, h.last_connected_at,
|
||||
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,
|
||||
jh.label AS jump_host_label
|
||||
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_hosts jh ON jh.id = h.jump_host_id
|
||||
{extra_where}
|
||||
@@ -906,11 +1010,47 @@ def create_host():
|
||||
label = (body.get("label") or "").strip()
|
||||
hostname = (body.get("hostname") or "").strip()
|
||||
port = int(body.get("port") or 22)
|
||||
use_inline = body.get("use_inline_identity", False)
|
||||
identity_id = body.get("identity_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
|
||||
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")
|
||||
folder_id = int(fid) if fid is not None and fid != "" else None
|
||||
if folder_id is not None:
|
||||
@@ -918,17 +1058,24 @@ def create_host():
|
||||
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
||||
if not cur.fetchone():
|
||||
return jsonify({"error": "folder not found"}), 400
|
||||
|
||||
with db_cursor() as (_, cur):
|
||||
if jump_host_id is not None:
|
||||
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
||||
if not cur.fetchone():
|
||||
return jsonify({"error": "jump host not found"}), 400
|
||||
|
||||
if identity_id:
|
||||
identity_id = int(identity_id)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||
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
|
||||
return jsonify({"id": hid}), 201
|
||||
@@ -940,6 +1087,53 @@ def update_host(hid: int):
|
||||
body = request.get_json(silent=True) or {}
|
||||
fields = []
|
||||
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:
|
||||
fields.append("label = %s")
|
||||
args.append(str(body["label"]).strip())
|
||||
@@ -949,7 +1143,7 @@ def update_host(hid: int):
|
||||
if "port" in body:
|
||||
fields.append("port = %s")
|
||||
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")
|
||||
args.append(int(body["identity_id"]))
|
||||
if "jump_host_id" in body:
|
||||
@@ -1001,22 +1195,36 @@ def delete_host(hid: int):
|
||||
@require_login
|
||||
def list_connection_audit():
|
||||
raw_limit = request.args.get("limit") or "200"
|
||||
raw_days = request.args.get("days_back")
|
||||
try:
|
||||
limit = int(raw_limit)
|
||||
except (TypeError, ValueError):
|
||||
limit = 200
|
||||
limit = max(1, min(limit, 500))
|
||||
|
||||
# Build the where clause for days filtering
|
||||
where_clause = ""
|
||||
params: list[Any] = []
|
||||
if raw_days is not None:
|
||||
try:
|
||||
days = int(raw_days)
|
||||
if days > 0:
|
||||
where_clause = "WHERE started_at >= DATE_SUB(NOW(), INTERVAL %s DAY)"
|
||||
params = [days]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
with db_cursor() as (_, cur):
|
||||
cur.execute(
|
||||
"""
|
||||
query = f"""
|
||||
SELECT id, host_id, host_label, hostname, port, jump_host_id,
|
||||
started_at, ended_at, duration_seconds
|
||||
FROM ssh_connection_audit
|
||||
{where_clause}
|
||||
ORDER BY id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
"""
|
||||
params.append(limit)
|
||||
cur.execute(query, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({"items": rows})
|
||||
|
||||
@@ -1118,6 +1326,9 @@ def ws_terminal():
|
||||
height=int(o.get("rows", 40)),
|
||||
)
|
||||
return True
|
||||
elif o.get("type") == "ping":
|
||||
# Ping message to keep connection alive, ignore without sending to channel
|
||||
return True
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
channel.send(msg.encode("utf-8"))
|
||||
|
||||
+442
-53
@@ -31,12 +31,14 @@ const searchQuery = ref("");
|
||||
const tabs = ref<TabItem[]>([]);
|
||||
const activeTabId = ref<string | null>(null);
|
||||
const loadErr = ref("");
|
||||
const hostSortOrder = ref<"name" | "last_connected">("name");
|
||||
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
|
||||
const sidebarOpen = ref(true);
|
||||
|
||||
let searchDebounceTimer = 0;
|
||||
|
||||
const showIdentityForm = ref(false);
|
||||
const showEditIdentity = ref(false);
|
||||
const showHostForm = ref(false);
|
||||
const showFolderForm = ref(false);
|
||||
const showEditHost = ref(false);
|
||||
@@ -44,6 +46,9 @@ const showAuditLog = ref(false);
|
||||
const auditLoading = ref(false);
|
||||
const auditErr = ref("");
|
||||
const auditRows = ref<ConnectionAuditRow[]>([]);
|
||||
const auditShowAll = ref(false);
|
||||
const deleteIdentityErr = ref("");
|
||||
const deleteIdentityErrId = ref<number | null>(null);
|
||||
const newFolderLabel = ref("");
|
||||
const identityForm = ref({
|
||||
label: "",
|
||||
@@ -53,11 +58,26 @@ const identityForm = ref({
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
});
|
||||
const editIdentityForm = ref({
|
||||
id: 0,
|
||||
label: "",
|
||||
auth_type: "password" as "password" | "publickey",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
});
|
||||
const hostForm = ref({
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
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,
|
||||
});
|
||||
const editHostForm = ref({
|
||||
@@ -65,7 +85,13 @@ const editHostForm = ref({
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
identity_id: 0,
|
||||
auth_type: "password" as "password" | "publickey",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
folder_id: null as number | null,
|
||||
jump_host_id: null as number | null,
|
||||
});
|
||||
@@ -86,6 +112,20 @@ function folderOptionLabel(id: number | null): string {
|
||||
return parts.join(" / ") || `#${id}`;
|
||||
}
|
||||
|
||||
function getSortedBrowseHosts(): HostRow[] {
|
||||
const hosts = [...browseHosts.value];
|
||||
if (hostSortOrder.value === "last_connected") {
|
||||
// Sort by last_connected_at descending (most recent first), with never-connected at end
|
||||
return hosts.sort((a, b) => {
|
||||
const aTime = a.last_connected_at ? new Date(a.last_connected_at).getTime() : 0;
|
||||
const bTime = b.last_connected_at ? new Date(b.last_connected_at).getTime() : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
// Sort alphabetically by label
|
||||
return hosts.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
async function refreshBrowse() {
|
||||
try {
|
||||
const d = await api.browse(currentFolderId.value, searchQuery.value);
|
||||
@@ -169,10 +209,24 @@ function fmtDuration(totalSeconds: number | null): string {
|
||||
|
||||
async function openAuditLog() {
|
||||
showAuditLog.value = true;
|
||||
auditShowAll.value = false;
|
||||
auditLoading.value = true;
|
||||
auditErr.value = "";
|
||||
try {
|
||||
auditRows.value = await api.listConnectionAudit(250);
|
||||
auditRows.value = await api.listConnectionAudit(250, 7);
|
||||
} catch (e) {
|
||||
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||
} finally {
|
||||
auditLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllAuditLog() {
|
||||
auditShowAll.value = true;
|
||||
auditLoading.value = true;
|
||||
auditErr.value = "";
|
||||
try {
|
||||
auditRows.value = await api.listConnectionAudit(500);
|
||||
} catch (e) {
|
||||
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||
} finally {
|
||||
@@ -225,22 +279,89 @@ async function submitIdentity() {
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
async function openEditIdentity(i: IdentityRow) {
|
||||
editIdentityForm.value = {
|
||||
id: i.id,
|
||||
label: i.label,
|
||||
auth_type: i.auth_type,
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
};
|
||||
showEditIdentity.value = true;
|
||||
}
|
||||
|
||||
async function submitEditIdentity() {
|
||||
const f = editIdentityForm.value;
|
||||
const body: Partial<{
|
||||
label: string;
|
||||
ssh_username: string;
|
||||
password: string;
|
||||
private_key: string;
|
||||
key_passphrase: string;
|
||||
}> = {
|
||||
label: f.label.trim(),
|
||||
};
|
||||
if (f.ssh_username.trim()) body.ssh_username = f.ssh_username.trim();
|
||||
if (f.auth_type === "password" && f.password) body.password = f.password;
|
||||
else if (f.auth_type === "publickey") {
|
||||
if (f.private_key) body.private_key = f.private_key;
|
||||
if (f.key_passphrase !== undefined) body.key_passphrase = f.key_passphrase;
|
||||
}
|
||||
await api.updateIdentity(f.id, body);
|
||||
showEditIdentity.value = false;
|
||||
editIdentityForm.value = {
|
||||
id: 0,
|
||||
label: "",
|
||||
auth_type: "password",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
};
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
async function submitHost() {
|
||||
const f = hostForm.value;
|
||||
await api.createHost({
|
||||
const body: Record<string, unknown> = {
|
||||
label: f.label.trim(),
|
||||
hostname: f.hostname.trim(),
|
||||
port: Number(f.port) || 22,
|
||||
identity_id: f.identity_id,
|
||||
folder_id: currentFolderId.value,
|
||||
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;
|
||||
hostForm.value = {
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
use_inline_identity: false,
|
||||
identity_id: hostForm.value.identity_id,
|
||||
auth_type: "password",
|
||||
ssh_username: "",
|
||||
password: "",
|
||||
private_key: "",
|
||||
key_passphrase: "",
|
||||
jump_host_id: null,
|
||||
};
|
||||
await refreshData();
|
||||
@@ -256,12 +377,19 @@ async function submitFolder() {
|
||||
}
|
||||
|
||||
function openEditHost(h: HostRow) {
|
||||
const hasInlineIdentity = h.identity_id === null;
|
||||
editHostForm.value = {
|
||||
id: h.id,
|
||||
label: h.label,
|
||||
hostname: h.hostname,
|
||||
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,
|
||||
jump_host_id: h.jump_host_id,
|
||||
};
|
||||
@@ -270,14 +398,31 @@ function openEditHost(h: HostRow) {
|
||||
|
||||
async function submitEditHost() {
|
||||
const f = editHostForm.value;
|
||||
await api.patchHost(f.id, {
|
||||
const body: Record<string, unknown> = {
|
||||
label: f.label.trim(),
|
||||
hostname: f.hostname.trim(),
|
||||
port: Number(f.port) || 22,
|
||||
identity_id: f.identity_id,
|
||||
folder_id: f.folder_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;
|
||||
allHosts.value = await api.listHosts();
|
||||
await refreshBrowse();
|
||||
@@ -311,9 +456,15 @@ async function deleteHostRow(id: number) {
|
||||
}
|
||||
|
||||
async function deleteIdentityRow(id: number) {
|
||||
if (!confirm("Remove this identity? Hosts using it may break.")) return;
|
||||
await api.deleteIdentity(id);
|
||||
await refreshData();
|
||||
if (!confirm("Remove this identity?")) return;
|
||||
deleteIdentityErr.value = "";
|
||||
try {
|
||||
await api.deleteIdentity(id);
|
||||
await refreshData();
|
||||
} catch (e) {
|
||||
deleteIdentityErr.value = e instanceof Error ? e.message : "Failed to delete identity";
|
||||
deleteIdentityErrId.value = id;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -478,9 +629,19 @@ async function deleteIdentityRow(id: number) {
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="browseHosts.length" class="mb-2 flex items-center justify-between">
|
||||
<label class="text-xs text-slate-500 uppercase">Sort by:</label>
|
||||
<select
|
||||
v-model="hostSortOrder"
|
||||
class="rounded border border-slate-700 bg-surface-overlay px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="last_connected">Last connected</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="h in browseHosts"
|
||||
v-for="h in getSortedBrowseHosts()"
|
||||
:key="'h' + h.id"
|
||||
class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
|
||||
>
|
||||
@@ -539,6 +700,16 @@ async function deleteIdentityRow(id: number) {
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Saved identities
|
||||
</div>
|
||||
<p v-if="deleteIdentityErr" class="mt-2 text-[11px] text-red-400">
|
||||
{{ deleteIdentityErr }}
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 underline hover:text-red-300"
|
||||
@click="deleteIdentityErr = ''"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</p>
|
||||
<ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
|
||||
<li
|
||||
v-for="i in identities"
|
||||
@@ -546,13 +717,22 @@ async function deleteIdentityRow(id: number) {
|
||||
class="flex items-center justify-between gap-1 py-0.5"
|
||||
>
|
||||
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-red-400/70 hover:underline"
|
||||
@click="deleteIdentityRow(i.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-slate-400/70 hover:text-slate-300 hover:underline"
|
||||
@click="openEditIdentity(i)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-red-400/70 hover:underline"
|
||||
@click="deleteIdentityRow(i.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -607,23 +787,32 @@ async function deleteIdentityRow(id: number) {
|
||||
<div
|
||||
v-if="showAuditLog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@click.self="showAuditLog = false"
|
||||
>
|
||||
<div
|
||||
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-white">Connection audit</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
@click="showAuditLog = false"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="!auditShowAll"
|
||||
type="button"
|
||||
class="rounded-lg bg-slate-800 px-3 py-1.5 text-xs hover:bg-slate-700"
|
||||
@click="loadAllAuditLog"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
@click="showAuditLog = false"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Recent SSH sessions and how long they lasted.
|
||||
Recent SSH sessions from the last 7 days and how long they lasted.
|
||||
</p>
|
||||
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</p>
|
||||
<p v-else-if="auditLoading" class="mt-3 text-xs text-slate-400">
|
||||
@@ -676,7 +865,6 @@ async function deleteIdentityRow(id: number) {
|
||||
<div
|
||||
v-if="showIdentityForm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@click.self="showIdentityForm = false"
|
||||
>
|
||||
<form
|
||||
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
@@ -746,10 +934,79 @@ async function deleteIdentityRow(id: number) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showEditIdentity"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
>
|
||||
<form
|
||||
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
@submit.prevent="submitEditIdentity"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-white">Edit identity</h2>
|
||||
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
|
||||
<input
|
||||
v-model="editIdentityForm.label"
|
||||
required
|
||||
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">Auth type</label>
|
||||
<div class="mt-1 rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm text-slate-400">
|
||||
{{ editIdentityForm.auth_type }}
|
||||
</div>
|
||||
<p class="mt-1 text-[10px] text-slate-500">Auth type cannot be changed</p>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">SSH username</label>
|
||||
<input
|
||||
v-model="editIdentityForm.ssh_username"
|
||||
placeholder="Leave empty to keep unchanged"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<template v-if="editIdentityForm.auth_type === 'password'">
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Password (optional)</label>
|
||||
<input
|
||||
v-model="editIdentityForm.password"
|
||||
type="password"
|
||||
placeholder="Leave empty to keep current password"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Private key (PEM, optional)</label>
|
||||
<textarea
|
||||
v-model="editIdentityForm.private_key"
|
||||
placeholder="Leave empty to keep current key"
|
||||
rows="6"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 font-mono text-xs"
|
||||
/>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||
<input
|
||||
v-model="editIdentityForm.key_passphrase"
|
||||
type="password"
|
||||
placeholder="Leave empty to remove passphrase"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</template>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
|
||||
@click="showEditIdentity = false"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showHostForm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@click.self="showHostForm = false"
|
||||
>
|
||||
<form
|
||||
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
@@ -779,16 +1036,84 @@ async function deleteIdentityRow(id: number) {
|
||||
max="65535"
|
||||
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>
|
||||
<select
|
||||
v-model.number="hostForm.identity_id"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</select>
|
||||
<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
|
||||
v-model.number="hostForm.identity_id"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</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>
|
||||
<select
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
@@ -816,7 +1141,7 @@ async function deleteIdentityRow(id: number) {
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
@@ -827,7 +1152,6 @@ async function deleteIdentityRow(id: number) {
|
||||
<div
|
||||
v-if="showFolderForm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@click.self="showFolderForm = false"
|
||||
>
|
||||
<form
|
||||
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
@@ -864,7 +1188,6 @@ async function deleteIdentityRow(id: number) {
|
||||
<div
|
||||
v-if="showEditHost"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@click.self="showEditHost = false"
|
||||
>
|
||||
<form
|
||||
class="max-h-[90vh] w-full max-w-md overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||
@@ -911,16 +1234,82 @@ async function deleteIdentityRow(id: number) {
|
||||
{{ folderOptionLabel(f.id) }}
|
||||
</option>
|
||||
</select>
|
||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
||||
<select
|
||||
v-model.number="editHostForm.identity_id"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</select>
|
||||
<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
|
||||
v-model.number="editHostForm.identity_id"
|
||||
required
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||
{{ i.label }} ({{ i.auth_type }})
|
||||
</option>
|
||||
</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>
|
||||
<select
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
|
||||
+26
-17
@@ -98,14 +98,7 @@ export const api = {
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async createHost(body: {
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
identity_id: number;
|
||||
folder_id?: number | null;
|
||||
jump_host_id?: number | null;
|
||||
}): Promise<{ id: number }> {
|
||||
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||
const res = await fetch("/api/hosts", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
@@ -117,14 +110,7 @@ export const api = {
|
||||
|
||||
async patchHost(
|
||||
id: number,
|
||||
body: Partial<{
|
||||
label: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
identity_id: number;
|
||||
folder_id: number | null;
|
||||
jump_host_id: number | null;
|
||||
}>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/hosts/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -153,6 +139,25 @@ export const api = {
|
||||
return handle(res);
|
||||
},
|
||||
|
||||
async updateIdentity(
|
||||
id: number,
|
||||
body: Partial<{
|
||||
label: string;
|
||||
ssh_username: string;
|
||||
password: string;
|
||||
private_key: string;
|
||||
key_passphrase: string;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/identities/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async deleteIdentity(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/identities/${id}`, {
|
||||
method: "DELETE",
|
||||
@@ -161,8 +166,11 @@ export const api = {
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
|
||||
async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
|
||||
const q = new URLSearchParams({ limit: String(limit) });
|
||||
if (daysBack !== undefined) {
|
||||
q.set("days_back", String(daysBack));
|
||||
}
|
||||
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
@@ -253,6 +261,7 @@ export interface HostRow {
|
||||
identity_label: string;
|
||||
identity_auth_type: string;
|
||||
folder_label?: string | null;
|
||||
last_connected_at?: string | null;
|
||||
}
|
||||
|
||||
export interface IdentityRow {
|
||||
|
||||
@@ -24,6 +24,7 @@ let ws: WebSocket | null = null;
|
||||
let term: Terminal | null = null;
|
||||
let fit: FitAddon | null = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
function wsUrl(hostId: number): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -86,6 +87,12 @@ onMounted(async () => {
|
||||
ws.onopen = () => {
|
||||
status.value = "Handshaking…";
|
||||
sendResize();
|
||||
// Send ping every 60 seconds to keep connection alive
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 60000);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
@@ -115,6 +122,10 @@ onMounted(async () => {
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
if (!connId.value) {
|
||||
status.value = "Disconnected";
|
||||
} else {
|
||||
@@ -124,6 +135,10 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
ws?.close();
|
||||
|
||||
Reference in New Issue
Block a user