16 Commits

Author SHA1 Message Date
jamie cae073728a Merge pull request 'fix: 🐛 branch name' (#11) from v1.0.0 into main
Reviewed-on: #11
2026-05-15 08:02:01 +01:00
jamie 0f35d5bd6f fix: 🐛 branch name
Release / release (pull_request) Successful in 26s
2026-05-15 07:01:38 +00:00
jamie 1d6cce88a8 Merge pull request 'feat: first release' (#10) from v1.0.0 into main
Reviewed-on: #10
2026-05-15 08:00:58 +01:00
jamie 18e256baee feat: first release 2026-05-15 07:00:35 +00:00
jamie 7682a94981 fix: 🐛 one-time credentials
CI / Build and Push (push) Successful in 4s
CI / SonarQube (push) Successful in 31s
2026-05-14 14:54:43 +00:00
jamie bb724377fe style: 🎨 more icons and ability to edit folders
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:44:41 +00:00
jamie 22a3dc7cbe style: 🎨 delete icon and reduce icon size
CI / Build and Push (push) Successful in 7s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:38:36 +00:00
jamie 6069f5395a style: 🎨 edit icon
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:34:04 +00:00
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
8 changed files with 902 additions and 113 deletions
@@ -1,8 +1,6 @@
name: CI name: CI
on: on:
push:
branches: [ main ]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -17,8 +15,8 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
docker build -t cr.jdbnet.co.uk/public/ssh:latest . docker build -t cr.jdbnet.co.uk/public/ssh:dev .
docker push cr.jdbnet.co.uk/public/ssh:latest docker push cr.jdbnet.co.uk/public/ssh:dev
sonarqube: sonarqube:
name: SonarQube name: SonarQube
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
# - name: Generate Changelog
# id: changelog
# uses: https://github.com/metcalfc/changelog-generator@v4.6.2
# with:
# myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ssh:$VERSION \
-t cr.jdbnet.co.uk/public/ssh:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/ssh:$VERSION
docker push cr.jdbnet.co.uk/public/ssh:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
+2
View File
@@ -8,6 +8,8 @@ RUN npm run build
FROM python:3.14-slim FROM python:3.14-slim
LABEL org.opencontainers.image.vendor="JDB-NET" LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app COPY . /app
+252 -24
View File
@@ -48,6 +48,7 @@ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
days=int(os.getenv("SESSION_DAYS", "14")) days=int(os.getenv("SESSION_DAYS", "14"))
) )
app.config["VERSION"] = os.getenv("VERSION", "unknown")
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"): if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_SECURE"] = True
@@ -111,11 +112,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 NULL,
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 +143,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 +169,88 @@ 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 modify identity_id to allow NULL
cur.execute(
"""
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ssh_hosts'
AND COLUMN_NAME = 'identity_id'
AND IS_NULLABLE = 'NO'
LIMIT 1
"""
)
if cur.fetchone() is not None:
cur.execute("ALTER TABLE ssh_hosts MODIFY COLUMN identity_id INT NULL")
# 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: def _like_escape(s: str) -> str:
return ( return (
s.replace("\\", "\\\\") s.replace("\\", "\\\\")
@@ -370,7 +457,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 +481,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 +501,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 +547,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,),
@@ -512,7 +614,13 @@ def _insert_connection_audit(host_row: dict[str, Any]) -> int | None:
host_row.get("jump_host_id"), 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: except Exception:
log.exception("failed to insert connection audit row") log.exception("failed to insert connection audit row")
return None return None
@@ -570,9 +678,10 @@ def api_logout():
@app.route("/api/me", methods=["GET"]) @app.route("/api/me", methods=["GET"])
def api_me(): def api_me():
version = app.config.get("VERSION", "unknown")
if session.get("logged_in"): if session.get("logged_in"):
return jsonify({"logged_in": True}) return jsonify({"logged_in": True, "app_version": version})
return jsonify({"logged_in": False}) return jsonify({"logged_in": False, "app_version": version})
@app.route("/api/identities", methods=["GET"]) @app.route("/api/identities", methods=["GET"])
@@ -699,6 +808,17 @@ def update_identity(iid: int):
@require_login @require_login
def delete_identity(iid: int): def delete_identity(iid: int):
with db_cursor() as (_, cur): 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,)) cur.execute("DELETE FROM ssh_identities WHERE id = %s", (iid,))
if cur.rowcount == 0: if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
@@ -708,12 +828,13 @@ def delete_identity(iid: int):
def _host_select_sql(extra_where: str = "") -> str: 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, h.last_connected_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}
@@ -906,11 +1027,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:
@@ -918,17 +1075,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
@@ -940,6 +1104,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())
@@ -949,7 +1160,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:
@@ -1001,22 +1212,36 @@ def delete_host(hid: int):
@require_login @require_login
def list_connection_audit(): def list_connection_audit():
raw_limit = request.args.get("limit") or "200" raw_limit = request.args.get("limit") or "200"
raw_days = request.args.get("days_back")
try: try:
limit = int(raw_limit) limit = int(raw_limit)
except (TypeError, ValueError): except (TypeError, ValueError):
limit = 200 limit = 200
limit = max(1, min(limit, 500)) 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): with db_cursor() as (_, cur):
cur.execute( query = f"""
"""
SELECT id, host_id, host_label, hostname, port, jump_host_id, SELECT id, host_id, host_label, hostname, port, jump_host_id,
started_at, ended_at, duration_seconds started_at, ended_at, duration_seconds
FROM ssh_connection_audit FROM ssh_connection_audit
{where_clause}
ORDER BY id DESC ORDER BY id DESC
LIMIT %s LIMIT %s
""", """
(limit,), params.append(limit)
) cur.execute(query, tuple(params))
rows = cur.fetchall() rows = cur.fetchall()
return jsonify({"items": rows}) return jsonify({"items": rows})
@@ -1118,6 +1343,9 @@ def ws_terminal():
height=int(o.get("rows", 40)), height=int(o.get("rows", 40)),
) )
return True 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): except (json.JSONDecodeError, TypeError, ValueError):
pass pass
channel.send(msg.encode("utf-8")) channel.send(msg.encode("utf-8"))
+541 -67
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { Folder } from "lucide-vue-next"; import { Folder, Pencil, Trash2 } from "lucide-vue-next";
import { import {
api, api,
type HostRow, type HostRow,
@@ -19,6 +19,7 @@ interface TabItem {
const loggedIn = ref(false); const loggedIn = ref(false);
const checking = ref(true); const checking = ref(true);
const appVersion = ref("unknown");
const identities = ref<IdentityRow[]>([]); const identities = ref<IdentityRow[]>([]);
const allHosts = ref<HostRow[]>([]); const allHosts = ref<HostRow[]>([]);
const allFolders = ref<FolderRow[]>([]); const allFolders = ref<FolderRow[]>([]);
@@ -31,12 +32,14 @@ const searchQuery = ref("");
const tabs = ref<TabItem[]>([]); const tabs = ref<TabItem[]>([]);
const activeTabId = ref<string | null>(null); const activeTabId = ref<string | null>(null);
const loadErr = ref(""); const loadErr = ref("");
const hostSortOrder = ref<"name" | "last_connected">("name");
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */ /** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
let searchDebounceTimer = 0; let searchDebounceTimer = 0;
const showIdentityForm = ref(false); const showIdentityForm = ref(false);
const showEditIdentity = ref(false);
const showHostForm = ref(false); const showHostForm = ref(false);
const showFolderForm = ref(false); const showFolderForm = ref(false);
const showEditHost = ref(false); const showEditHost = ref(false);
@@ -44,7 +47,16 @@ const showAuditLog = ref(false);
const auditLoading = ref(false); const auditLoading = ref(false);
const auditErr = ref(""); const auditErr = ref("");
const auditRows = ref<ConnectionAuditRow[]>([]); const auditRows = ref<ConnectionAuditRow[]>([]);
const auditShowAll = ref(false);
const deleteIdentityErr = ref("");
const deleteIdentityErrId = ref<number | null>(null);
const newFolderLabel = ref(""); const newFolderLabel = ref("");
const showEditFolder = ref(false);
const editFolderForm = ref({
id: 0,
label: "",
parent_id: null as number | null,
});
const identityForm = ref({ const identityForm = ref({
label: "", label: "",
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -53,11 +65,26 @@ const identityForm = ref({
private_key: "", private_key: "",
key_passphrase: "", key_passphrase: "",
}); });
const editIdentityForm = ref({
id: 0,
label: "",
auth_type: "password" as "password" | "publickey",
ssh_username: "",
password: "",
private_key: "",
key_passphrase: "",
});
const hostForm = ref({ 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({
@@ -65,7 +92,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,
}); });
@@ -86,6 +119,20 @@ function folderOptionLabel(id: number | null): string {
return parts.join(" / ") || `#${id}`; 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() { async function refreshBrowse() {
try { try {
const d = await api.browse(currentFolderId.value, searchQuery.value); const d = await api.browse(currentFolderId.value, searchQuery.value);
@@ -130,6 +177,9 @@ onMounted(async () => {
try { try {
const m = await api.me(); const m = await api.me();
loggedIn.value = m.logged_in; loggedIn.value = m.logged_in;
if (m.app_version) {
appVersion.value = m.app_version;
}
if (loggedIn.value) await refreshData(); if (loggedIn.value) await refreshData();
} catch { } catch {
loggedIn.value = false; loggedIn.value = false;
@@ -169,10 +219,24 @@ function fmtDuration(totalSeconds: number | null): string {
async function openAuditLog() { async function openAuditLog() {
showAuditLog.value = true; showAuditLog.value = true;
auditShowAll.value = false;
auditLoading.value = true; auditLoading.value = true;
auditErr.value = ""; auditErr.value = "";
try { 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) { } catch (e) {
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log"; auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
} finally { } finally {
@@ -225,22 +289,89 @@ async function submitIdentity() {
await refreshData(); 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() { 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();
@@ -255,13 +386,39 @@ async function submitFolder() {
await refreshBrowse(); await refreshBrowse();
} }
function openEditFolder(f: FolderRow) {
editFolderForm.value = {
id: f.id,
label: f.label,
parent_id: f.parent_id,
};
showEditFolder.value = true;
}
async function submitEditFolder() {
const f = editFolderForm.value;
await api.updateFolder(f.id, {
label: f.label.trim(),
parent_id: f.parent_id,
});
showEditFolder.value = false;
await refreshBrowse();
}
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,
}; };
@@ -270,14 +427,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();
@@ -311,9 +485,15 @@ async function deleteHostRow(id: number) {
} }
async function deleteIdentityRow(id: number) { async function deleteIdentityRow(id: number) {
if (!confirm("Remove this identity? Hosts using it may break.")) return; if (!confirm("Remove this identity?")) return;
await api.deleteIdentity(id); deleteIdentityErr.value = "";
await refreshData(); try {
await api.deleteIdentity(id);
await refreshData();
} catch (e) {
deleteIdentityErr.value = e instanceof Error ? e.message : "Failed to delete identity";
deleteIdentityErrId.value = id;
}
} }
</script> </script>
@@ -351,7 +531,15 @@ async function deleteIdentityRow(id: number) {
/> />
</svg> </svg>
</button> </button>
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span> <a
href="https://git.jdbnet.co.uk/jamie/ssh"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 truncate"
>
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
<span class="truncate text-xs text-slate-400 hover:text-slate-300">{{ appVersion }}</span>
</a>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -469,18 +657,39 @@ async function deleteIdentityRow(id: number) {
<span>{{ f.label }}</span> <span>{{ f.label }}</span>
</span> </span>
</button> </button>
<button <div class="flex gap-1 shrink-0">
type="button" <button
class="shrink-0 text-[10px] text-red-400/80 hover:underline" type="button"
@click="deleteFolderRow(f.id)" class="text-slate-400/70 hover:text-slate-300"
> title="Rename folder"
Del @click="openEditFolder(f)"
</button> >
<Pencil class="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
class="text-red-400/70 hover:text-red-400"
title="Delete folder"
@click="deleteFolderRow(f.id)"
>
<Trash2 class="h-3 w-3" aria-hidden="true" />
</button>
</div>
</li> </li>
</ul> </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"> <ul class="space-y-1">
<li <li
v-for="h in browseHosts" v-for="h in getSortedBrowseHosts()"
:key="'h' + h.id" :key="'h' + h.id"
class="rounded-lg border border-slate-800 bg-surface-overlay p-2" class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
> >
@@ -506,20 +715,22 @@ async function deleteIdentityRow(id: number) {
> >
{{ folderOptionLabel(h.folder_id) }} {{ folderOptionLabel(h.folder_id) }}
</p> </p>
<div class="mt-1 flex gap-2"> <div class="mt-1 flex gap-1">
<button <button
type="button" type="button"
class="text-[10px] text-slate-400 hover:text-white hover:underline" class="text-slate-400/70 hover:text-slate-300"
title="Edit host"
@click="openEditHost(h)" @click="openEditHost(h)"
> >
Edit <Pencil class="h-3 w-3" aria-hidden="true" />
</button> </button>
<button <button
type="button" type="button"
class="text-[10px] text-red-400/80 hover:underline" class="text-red-400/70 hover:text-red-400"
title="Delete host"
@click="deleteHostRow(h.id)" @click="deleteHostRow(h.id)"
> >
Remove <Trash2 class="h-3 w-3" aria-hidden="true" />
</button> </button>
</div> </div>
</li> </li>
@@ -539,6 +750,16 @@ async function deleteIdentityRow(id: number) {
<div class="text-xs font-medium uppercase tracking-wide text-slate-500"> <div class="text-xs font-medium uppercase tracking-wide text-slate-500">
Saved identities Saved identities
</div> </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"> <ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
<li <li
v-for="i in identities" v-for="i in identities"
@@ -546,13 +767,24 @@ async function deleteIdentityRow(id: number) {
class="flex items-center justify-between gap-1 py-0.5" class="flex items-center justify-between gap-1 py-0.5"
> >
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span> <span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
<button <div class="flex gap-1">
type="button" <button
class="shrink-0 text-red-400/70 hover:underline" type="button"
@click="deleteIdentityRow(i.id)" class="shrink-0 text-slate-400/70 hover:text-slate-300"
> title="Edit identity"
× @click="openEditIdentity(i)"
</button> >
<Pencil class="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
class="shrink-0 text-red-400/70 hover:text-red-400"
title="Delete identity"
@click="deleteIdentityRow(i.id)"
>
<Trash2 class="h-3 w-3" aria-hidden="true" />
</button>
</div>
</li> </li>
</ul> </ul>
</div> </div>
@@ -607,23 +839,32 @@ async function deleteIdentityRow(id: number) {
<div <div
v-if="showAuditLog" v-if="showAuditLog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showAuditLog = false"
> >
<div <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" 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"> <div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-white">Connection audit</h2> <h2 class="text-lg font-semibold text-white">Connection audit</h2>
<button <div class="flex gap-2">
type="button" <button
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white" v-if="!auditShowAll"
@click="showAuditLog = false" type="button"
> class="rounded-lg bg-slate-800 px-3 py-1.5 text-xs hover:bg-slate-700"
Close @click="loadAllAuditLog"
</button> >
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> </div>
<p class="mt-1 text-xs text-slate-500"> <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>
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</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"> <p v-else-if="auditLoading" class="mt-3 text-xs text-slate-400">
@@ -676,7 +917,6 @@ async function deleteIdentityRow(id: number) {
<div <div
v-if="showIdentityForm" v-if="showIdentityForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showIdentityForm = false"
> >
<form <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" 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 +986,79 @@ async function deleteIdentityRow(id: number) {
</form> </form>
</div> </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 <div
v-if="showHostForm" v-if="showHostForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showHostForm = false"
> >
<form <form
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl" class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@@ -779,16 +1088,84 @@ 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>
<select <div class="mt-1 flex gap-2">
v-model.number="hostForm.identity_id" <label class="flex items-center gap-2 cursor-pointer">
required <input
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" type="radio"
> :checked="!hostForm.use_inline_identity"
<option v-for="i in identities" :key="i.id" :value="i.id"> @change="hostForm.use_inline_identity = false"
{{ i.label }} ({{ i.auth_type }}) class="w-4 h-4"
</option> />
</select> <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> <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"
@@ -816,7 +1193,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>
@@ -827,7 +1204,6 @@ async function deleteIdentityRow(id: number) {
<div <div
v-if="showFolderForm" v-if="showFolderForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showFolderForm = false"
> >
<form <form
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl" class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@@ -861,10 +1237,42 @@ async function deleteIdentityRow(id: number) {
</form> </form>
</div> </div>
<div
v-if="showEditFolder"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<form
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitEditFolder"
>
<h2 class="text-lg font-semibold text-white">Rename folder</h2>
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
<input
v-model="editFolderForm.label"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<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="showEditFolder = 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 <div
v-if="showEditHost" v-if="showEditHost"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showEditHost = false"
> >
<form <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" 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 +1319,82 @@ 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>
<select <div class="mt-1 flex gap-2">
v-model.number="editHostForm.identity_id" <label class="flex items-center gap-2 cursor-pointer">
required <input
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" type="radio"
> :checked="!editHostForm.use_inline_identity"
<option v-for="i in identities" :key="i.id" :value="i.id"> @change="editHostForm.use_inline_identity = false"
{{ i.label }} ({{ i.auth_type }}) class="w-4 h-4"
</option> />
</select> <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> <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"
+43 -18
View File
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
} }
export const api = { export const api = {
async me(): Promise<{ logged_in: boolean }> { async me(): Promise<{ logged_in: boolean; app_version?: string }> {
const res = await fetch("/api/me", { credentials: "include" }); const res = await fetch("/api/me", { credentials: "include" });
return handle(res); return handle(res);
}, },
@@ -92,20 +92,29 @@ export const api = {
await handle(res); await handle(res);
}, },
async updateFolder(
id: number,
body: {
label?: string;
parent_id?: number | null;
},
): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async listIdentities(): Promise<IdentityRow[]> { async listIdentities(): Promise<IdentityRow[]> {
const res = await fetch("/api/identities", { credentials: "include" }); const res = await fetch("/api/identities", { credentials: "include" });
const d = await handle<{ items: IdentityRow[] }>(res); const d = await handle<{ items: IdentityRow[] }>(res);
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 +126,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",
@@ -153,6 +155,25 @@ export const api = {
return handle(res); 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> { async deleteIdentity(id: number): Promise<void> {
const res = await fetch(`/api/identities/${id}`, { const res = await fetch(`/api/identities/${id}`, {
method: "DELETE", method: "DELETE",
@@ -161,8 +182,11 @@ export const api = {
await handle(res); await handle(res);
}, },
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> { async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
const q = new URLSearchParams({ limit: String(limit) }); 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()}`, { const res = await fetch(`/api/audit/connections?${q.toString()}`, {
credentials: "include", credentials: "include",
}); });
@@ -253,6 +277,7 @@ export interface HostRow {
identity_label: string; identity_label: string;
identity_auth_type: string; identity_auth_type: string;
folder_label?: string | null; folder_label?: string | null;
last_connected_at?: string | null;
} }
export interface IdentityRow { export interface IdentityRow {
+15
View File
@@ -24,6 +24,7 @@ let ws: WebSocket | null = null;
let term: Terminal | null = null; let term: Terminal | null = null;
let fit: FitAddon | null = null; let fit: FitAddon | null = null;
let ro: ResizeObserver | null = null; let ro: ResizeObserver | null = null;
let pingInterval: NodeJS.Timeout | null = null;
function wsUrl(hostId: number): string { function wsUrl(hostId: number): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -86,6 +87,12 @@ onMounted(async () => {
ws.onopen = () => { ws.onopen = () => {
status.value = "Handshaking…"; status.value = "Handshaking…";
sendResize(); 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) => { ws.onmessage = (ev) => {
@@ -115,6 +122,10 @@ onMounted(async () => {
}; };
ws.onclose = () => { ws.onclose = () => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (!connId.value) { if (!connId.value) {
status.value = "Disconnected"; status.value = "Disconnected";
} else { } else {
@@ -124,6 +135,10 @@ onMounted(async () => {
}); });
onUnmounted(() => { onUnmounted(() => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
ro?.disconnect(); ro?.disconnect();
ro = null; ro = null;
ws?.close(); ws?.close();
Submodule
+1
Submodule jdbnet.co.uk added at 4c2697f274