Compare commits

..

8 Commits

Author SHA1 Message Date
jamie d542264567 feat: connection audit shows last 7 days
CI / Build and Push (push) Successful in 26s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:08:06 +00:00
jamie 7f717684eb feat: sort hosts by alphabetically or last connected 2026-05-14 12:03:20 +00:00
jamie 90103f79c8 fix: 🐛 client sends ping to keep websocket connection alive 2026-05-14 11:56:33 +00:00
jamie 20db4742a7 feat: one time credentials for hosts 2026-05-14 11:53:41 +00:00
jamie ca8b5dea7f ci: 🚀 dev build 2026-05-14 11:47:22 +00:00
jamie ca3d27e7f9 fix: 🐛 don't close modals if you click outside 2026-05-14 11:43:55 +00:00
jamie 035f871b00 feat: edit identities 2026-05-14 11:35:59 +00:00
jamie 5bba2947c4 refactor: 🎨 when deleting an identity it tells you if it fails 2026-05-14 11:33:46 +00:00
6 changed files with 764 additions and 94 deletions
-2
View File
@@ -1,8 +1,6 @@
name: CI
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
+48
View File
@@ -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
+233 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+15
View File
@@ -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();