6 Commits

Author SHA1 Message Date
jamie da996705c9 Merge pull request 'feat: added api key support' (#13) from v1.1.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ssh/pulls/13
2026-05-23 16:42:43 +01:00
jamie 6d341309f1 docs: 📝 remove unrequired variable from example
Release / release (pull_request) Successful in 24s
2026-05-23 15:42:22 +00:00
jamie 187a3c7882 fix: 🐛 client timeouts 2026-05-23 15:40:13 +00:00
jamie c7ffdf81c2 feat: add tags 2026-05-23 15:36:46 +00:00
jamie 0664d8763d feat: added api key support 2026-05-23 15:24:35 +00:00
jamie 853e06456e test: remove dummy project 2026-05-15 07:05:02 +00:00
10 changed files with 1131 additions and 82 deletions
+5
View File
@@ -1,5 +1,10 @@
FROM mcr.microsoft.com/devcontainers/python:3.14 FROM mcr.microsoft.com/devcontainers/python:3.14
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace WORKDIR /workspace
CMD ["sleep", "infinity"] CMD ["sleep", "infinity"]
+4 -1
View File
@@ -26,4 +26,7 @@ MYSQL_POOL_SIZE=5
MAX_CONCURRENT_SSH=32 MAX_CONCURRENT_SSH=32
# Paramiko SSH keepalive interval (seconds); set 0 to disable. # Paramiko SSH keepalive interval (seconds); set 0 to disable.
SSH_KEEPALIVE_INTERVAL=30 SSH_KEEPALIVE_INTERVAL=15
# WebSocket keepalive interval (seconds); server sends traffic to avoid proxy idle timeouts.
WS_KEEPALIVE_INTERVAL=25
+5 -5
View File
@@ -20,11 +20,11 @@ jobs:
id: get_version id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
# - name: Generate Changelog - name: Generate Changelog
# id: changelog id: changelog
# uses: https://github.com/metcalfc/changelog-generator@v4.6.2 uses: https://github.com/metcalfc/changelog-generator@v4.6.2
# with: with:
# myToken: ${{ secrets.GITHUB_TOKEN }} myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
-1
View File
@@ -30,7 +30,6 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
GEVENT_MONKEY_PATCH: "1"
MYSQL_HOST: "<YOUR_MYSQL_HOST>" MYSQL_HOST: "<YOUR_MYSQL_HOST>"
MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>" MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>"
MYSQL_USER: "<YOUR_MYSQL_USER>" MYSQL_USER: "<YOUR_MYSQL_USER>"
+573 -44
View File
@@ -15,14 +15,16 @@ import hashlib
import hmac import hmac
import io import io
import json import json
import secrets
import logging import logging
import posixpath import posixpath
import queue import queue
import re
import threading import threading
import time import time
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import timedelta from datetime import datetime, timedelta, timezone
from functools import wraps from functools import wraps
from typing import Any from typing import Any
@@ -32,7 +34,7 @@ import paramiko
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import Flask, jsonify, request, session, send_from_directory, send_file, abort, Response from flask import Flask, jsonify, request, session, send_from_directory, send_file, abort, Response
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import safe_join, secure_filename from werkzeug.utils import safe_join, secure_filename
from simple_websocket import ConnectionClosed, Server from simple_websocket import ConnectionClosed, Server
@@ -136,6 +138,32 @@ def init_db():
CONSTRAINT fk_audit_host FOREIGN KEY (host_id) CONSTRAINT fk_audit_host FOREIGN KEY (host_id)
REFERENCES ssh_hosts(id) ON DELETE SET NULL REFERENCES ssh_hosts(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS api_keys (
id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(255) NOT NULL,
key_prefix VARCHAR(24) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
scopes JSON NOT NULL,
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_prefix (key_prefix)
);
CREATE TABLE IF NOT EXISTS ssh_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
UNIQUE KEY uq_ssh_tags_name (name)
);
CREATE TABLE IF NOT EXISTS ssh_host_tags (
host_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (host_id, tag_id),
CONSTRAINT fk_host_tags_host FOREIGN KEY (host_id)
REFERENCES ssh_hosts(id) ON DELETE CASCADE,
CONSTRAINT fk_host_tags_tag FOREIGN KEY (tag_id)
REFERENCES ssh_tags(id) ON DELETE CASCADE
);
""" """
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
for stmt in ddl.split(";"): for stmt in ddl.split(";"):
@@ -259,6 +287,92 @@ def _like_escape(s: str) -> str:
) )
_TAG_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _normalize_tag_name(raw: str) -> str | None:
name = re.sub(r"\s+", "", raw.strip().lower())
if not name or not _TAG_NAME_RE.match(name):
return None
return name
def _parse_host_tags(raw: Any) -> list[str] | None:
if raw is None:
return []
if not isinstance(raw, list):
return None
tags: list[str] = []
seen: set[str] = set()
for item in raw:
if not isinstance(item, str):
return None
norm = _normalize_tag_name(item)
if norm is None:
return None
if norm not in seen:
seen.add(norm)
tags.append(norm)
return sorted(tags)
def _parse_search_query(q: str) -> tuple[str, str]:
if q.lower().startswith("tag:"):
return "tag", q[4:].strip()
return "text", q
def _host_tag_list_sql() -> str:
return """
(SELECT GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',')
FROM ssh_host_tags ht
INNER JOIN ssh_tags t ON t.id = ht.tag_id
WHERE ht.host_id = h.id) AS tag_list
"""
def _serialize_host_row(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
tag_list = out.pop("tag_list", None)
out["tags"] = tag_list.split(",") if tag_list else []
return out
def _serialize_host_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [_serialize_host_row(r) for r in rows]
def _tag_id(cur, name: str) -> int:
cur.execute(
"INSERT INTO ssh_tags (name) VALUES (%s) ON DUPLICATE KEY UPDATE name = name",
(name,),
)
cur.execute("SELECT id FROM ssh_tags WHERE name = %s", (name,))
row = cur.fetchone()
assert row is not None
return int(row["id"])
def _set_host_tags(cur, host_id: int, tags: list[str]) -> None:
cur.execute("DELETE FROM ssh_host_tags WHERE host_id = %s", (host_id,))
for name in tags:
cur.execute(
"INSERT INTO ssh_host_tags (host_id, tag_id) VALUES (%s, %s)",
(host_id, _tag_id(cur, name)),
)
def _host_tag_filter_sql(tag_name: str) -> tuple[str, tuple[Any, ...]]:
return (
"h.id IN ("
"SELECT ht.host_id FROM ssh_host_tags ht "
"INNER JOIN ssh_tags t ON t.id = ht.tag_id "
"WHERE t.name = %s"
")",
(tag_name,),
)
def _folder_subtree_ids(cur, root_id: int) -> list[int]: def _folder_subtree_ids(cur, root_id: int) -> list[int]:
cur.execute( cur.execute(
""" """
@@ -337,6 +451,153 @@ def require_login(fn):
return wrapped return wrapped
VALID_API_SCOPES = frozenset(
{
"read:hosts",
"write:hosts",
"read:audit",
"terminal:connect",
"sftp:manage",
}
)
def _parse_api_key_scopes(raw: Any) -> set[str]:
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return set()
if not isinstance(raw, list):
return set()
return {s for s in raw if isinstance(s, str) and s in VALID_API_SCOPES}
def _bearer_token_from_request() -> str | None:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:].strip()
return token or None
return None
def _authenticate_api_key(token: str) -> dict[str, Any] | None:
if not token.startswith("ssh_k_"):
return None
parts = token.split("_")
if len(parts) < 4 or parts[0] != "ssh" or parts[1] != "k" or len(parts[2]) != 8:
return None
key_prefix = f"ssh_k_{parts[2]}"
with db_cursor() as (_, cur):
cur.execute(
"""
SELECT id, key_hash, scopes, expires_at, revoked_at
FROM api_keys
WHERE key_prefix = %s
LIMIT 1
""",
(key_prefix,),
)
row = cur.fetchone()
if not row or row.get("revoked_at"):
return None
expires_at = row.get("expires_at")
if expires_at is not None and expires_at <= _utcnow():
return None
if not check_password_hash(row["key_hash"], token):
return None
scopes = _parse_api_key_scopes(row.get("scopes"))
if not scopes:
return None
with db_cursor() as (_, cur):
cur.execute(
"UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = %s",
(row["id"],),
)
return {"type": "api_key", "id": row["id"], "scopes": scopes}
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def resolve_auth() -> dict[str, Any] | None:
if session.get("logged_in"):
return {"type": "session", "scopes": set(VALID_API_SCOPES)}
token = _bearer_token_from_request()
if token:
return _authenticate_api_key(token)
return None
def _ws_resolve_auth() -> dict[str, Any] | None:
if session.get("logged_in"):
return {"type": "session", "scopes": set(VALID_API_SCOPES)}
token = (request.args.get("token") or "").strip()
if not token:
token = _bearer_token_from_request() or ""
if token:
return _authenticate_api_key(token)
return None
def _auth_has_scopes(auth: dict[str, Any], required: tuple[str, ...]) -> bool:
if not required:
return True
scopes = auth.get("scopes") or set()
return all(scope in scopes for scope in required)
def require_auth(*required_scopes: str):
def decorator(fn):
@wraps(fn)
def wrapped(*args, **kwargs):
auth = resolve_auth()
if not auth:
return jsonify({"error": "unauthorized"}), 401
if not _auth_has_scopes(auth, required_scopes):
return jsonify({"error": "forbidden"}), 403
return fn(*args, **kwargs)
return wrapped
return decorator
def _validate_api_key_scopes(raw: Any) -> list[str] | None:
if not isinstance(raw, list):
return None
scopes = sorted(_parse_api_key_scopes(raw))
if not scopes:
return None
return scopes
def _generate_api_key_material() -> tuple[str, str, str]:
key_prefix = f"ssh_k_{secrets.token_hex(4)}"
full_key = f"{key_prefix}_{secrets.token_urlsafe(32)}"
return full_key, key_prefix, generate_password_hash(full_key)
def _parse_optional_expires_at(raw: Any) -> Any:
if raw is None or raw == "":
return None
if not isinstance(raw, str):
return "invalid"
text = raw.strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(text)
except ValueError:
return "invalid"
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
_registry_lock = threading.Lock() _registry_lock = threading.Lock()
_connections: dict[str, dict[str, Any]] = {} _connections: dict[str, dict[str, Any]] = {}
@@ -383,7 +644,26 @@ def _close_ssh_entry(entry: dict[str, Any]) -> None:
MAX_CONCURRENT_SSH = int(os.getenv("MAX_CONCURRENT_SSH", "32")) MAX_CONCURRENT_SSH = int(os.getenv("MAX_CONCURRENT_SSH", "32"))
SSH_KEEPALIVE_INTERVAL = int(os.getenv("SSH_KEEPALIVE_INTERVAL", "30")) SSH_KEEPALIVE_INTERVAL = int(os.getenv("SSH_KEEPALIVE_INTERVAL", "15"))
WS_KEEPALIVE_INTERVAL = int(os.getenv("WS_KEEPALIVE_INTERVAL", "25"))
def _apply_ssh_keepalive(client: paramiko.SSHClient) -> None:
if SSH_KEEPALIVE_INTERVAL <= 0:
return
transport = client.get_transport()
if transport is not None:
transport.set_keepalive(SSH_KEEPALIVE_INTERVAL)
def _ssh_transports_alive(
client: paramiko.SSHClient, jump_clients: list[paramiko.SSHClient] | None
) -> bool:
for ssh_client in (client, *(jump_clients or [])):
transport = ssh_client.get_transport()
if transport is None or not transport.is_active():
return False
return True
class GeventWsAppResponse(Response): class GeventWsAppResponse(Response):
@@ -534,9 +814,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
) )
if SSH_KEEPALIVE_INTERVAL > 0: if SSH_KEEPALIVE_INTERVAL > 0:
transport = client.get_transport() _apply_ssh_keepalive(client)
if transport is not None:
transport.set_keepalive(SSH_KEEPALIVE_INTERVAL)
chan = client.invoke_shell(term="xterm-256color", width=120, height=40) chan = client.invoke_shell(term="xterm-256color", width=120, height=40)
chan.setblocking(True) chan.setblocking(True)
@@ -685,7 +963,7 @@ def api_me():
@app.route("/api/identities", methods=["GET"]) @app.route("/api/identities", methods=["GET"])
@require_login @require_auth("read:hosts")
def list_identities(): def list_identities():
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute( cur.execute(
@@ -696,7 +974,7 @@ def list_identities():
@app.route("/api/identities", methods=["POST"]) @app.route("/api/identities", methods=["POST"])
@require_login @require_auth("write:hosts")
def create_identity(): def create_identity():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip() label = (body.get("label") or "").strip()
@@ -739,7 +1017,7 @@ def create_identity():
@app.route("/api/identities/<int:iid>", methods=["PATCH"]) @app.route("/api/identities/<int:iid>", methods=["PATCH"])
@require_login @require_auth("write:hosts")
def update_identity(iid: int): def update_identity(iid: int):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
@@ -805,7 +1083,7 @@ def update_identity(iid: int):
@app.route("/api/identities/<int:iid>", methods=["DELETE"]) @app.route("/api/identities/<int:iid>", methods=["DELETE"])
@require_login @require_auth("write:hosts")
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 # Check if identity is being used by any hosts
@@ -829,10 +1107,11 @@ 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.last_connected_at, h.created_at, h.updated_at, h.last_connected_at,
COALESCE(i.label, 'One-time') AS identity_label, COALESCE(i.label, 'One-time') AS identity_label,
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type, 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,
{_host_tag_list_sql()}
FROM ssh_hosts h FROM ssh_hosts h
LEFT 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
@@ -841,7 +1120,7 @@ def _host_select_sql(extra_where: str = "") -> str:
""" """
@app.route("/api/folders", methods=["GET"]) @app.route("/api/folders", methods=["GET"])
@require_login @require_auth("read:hosts")
def list_all_folders(): def list_all_folders():
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute( cur.execute(
@@ -852,7 +1131,7 @@ def list_all_folders():
@app.route("/api/folders", methods=["POST"]) @app.route("/api/folders", methods=["POST"])
@require_login @require_auth("write:hosts")
def create_folder(): def create_folder():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip() label = (body.get("label") or "").strip()
@@ -875,7 +1154,7 @@ def create_folder():
@app.route("/api/folders/<int:fid>", methods=["PATCH"]) @app.route("/api/folders/<int:fid>", methods=["PATCH"])
@require_login @require_auth("write:hosts")
def update_folder(fid: int): def update_folder(fid: int):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
@@ -915,7 +1194,7 @@ def update_folder(fid: int):
@app.route("/api/folders/<int:fid>", methods=["DELETE"]) @app.route("/api/folders/<int:fid>", methods=["DELETE"])
@require_login @require_auth("write:hosts")
def delete_folder(fid: int): def delete_folder(fid: int):
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute("DELETE FROM ssh_folders WHERE id = %s", (fid,)) cur.execute("DELETE FROM ssh_folders WHERE id = %s", (fid,))
@@ -925,7 +1204,7 @@ def delete_folder(fid: int):
@app.route("/api/browse", methods=["GET"]) @app.route("/api/browse", methods=["GET"])
@require_login @require_auth("read:hosts")
def api_browse(): def api_browse():
raw_fid = request.args.get("folder_id") raw_fid = request.args.get("folder_id")
if raw_fid in (None, "", "root"): if raw_fid in (None, "", "root"):
@@ -936,8 +1215,7 @@ def api_browse():
except (TypeError, ValueError): except (TypeError, ValueError):
return jsonify({"error": "invalid folder_id"}), 400 return jsonify({"error": "invalid folder_id"}), 400
q = (request.args.get("q") or "").strip() q = (request.args.get("q") or "").strip()
esc = _like_escape(q) if q else "" search_mode, search_term = _parse_search_query(q)
pat = f"%{esc}%" if q else ""
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
breadcrumb: list[dict[str, Any]] = [] breadcrumb: list[dict[str, Any]] = []
@@ -948,6 +1226,30 @@ def api_browse():
breadcrumb = _folder_breadcrumb_rows(cur, folder_id) breadcrumb = _folder_breadcrumb_rows(cur, folder_id)
if q: if q:
if search_mode == "tag":
tag_name = _normalize_tag_name(search_term)
if tag_name is None:
hosts: list[dict[str, Any]] = []
else:
tag_where, tag_args = _host_tag_filter_sql(tag_name)
cur.execute(
_host_select_sql(f"WHERE {tag_where}") + " ORDER BY h.label",
tag_args,
)
hosts = _serialize_host_rows(cur.fetchall())
return jsonify(
{
"breadcrumb": breadcrumb,
"folders": [],
"hosts": hosts,
"search_active": True,
"search_mode": "tag",
"search_tag": tag_name,
}
)
esc = _like_escape(search_term)
pat = f"%{esc}%"
if folder_id is None: if folder_id is None:
cur.execute( cur.execute(
_host_select_sql( _host_select_sql(
@@ -956,7 +1258,7 @@ def api_browse():
+ " ORDER BY h.label", + " ORDER BY h.label",
(pat, pat), (pat, pat),
) )
hosts = cur.fetchall() hosts = _serialize_host_rows(cur.fetchall())
else: else:
ids = _folder_subtree_ids(cur, folder_id) ids = _folder_subtree_ids(cur, folder_id)
if not ids: if not ids:
@@ -971,13 +1273,14 @@ def api_browse():
+ " ORDER BY h.label", + " ORDER BY h.label",
(*ids, pat, pat), (*ids, pat, pat),
) )
hosts = cur.fetchall() hosts = _serialize_host_rows(cur.fetchall())
return jsonify( return jsonify(
{ {
"breadcrumb": breadcrumb, "breadcrumb": breadcrumb,
"folders": [], "folders": [],
"hosts": hosts, "hosts": hosts,
"search_active": True, "search_active": True,
"search_mode": "text",
} }
) )
@@ -999,7 +1302,7 @@ def api_browse():
_host_select_sql("WHERE h.folder_id = %s") + " ORDER BY h.label", _host_select_sql("WHERE h.folder_id = %s") + " ORDER BY h.label",
(folder_id,), (folder_id,),
) )
hosts = cur.fetchall() hosts = _serialize_host_rows(cur.fetchall())
return jsonify( return jsonify(
{ {
@@ -1012,16 +1315,25 @@ def api_browse():
@app.route("/api/hosts", methods=["GET"]) @app.route("/api/hosts", methods=["GET"])
@require_login @require_auth("read:hosts")
def list_hosts(): def list_hosts():
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute(_host_select_sql("") + " ORDER BY h.label") cur.execute(_host_select_sql("") + " ORDER BY h.label")
rows = cur.fetchall() rows = _serialize_host_rows(cur.fetchall())
return jsonify({"items": rows}) return jsonify({"items": rows})
@app.route("/api/tags", methods=["GET"])
@require_auth("read:hosts")
def list_tags():
with db_cursor() as (_, cur):
cur.execute("SELECT name FROM ssh_tags ORDER BY name")
rows = cur.fetchall()
return jsonify({"items": [r["name"] for r in rows]})
@app.route("/api/hosts", methods=["POST"]) @app.route("/api/hosts", methods=["POST"])
@require_login @require_auth("write:hosts")
def create_host(): def create_host():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip() label = (body.get("label") or "").strip()
@@ -1034,6 +1346,12 @@ def create_host():
if not label or not hostname: if not label or not hostname:
return jsonify({"error": "label, hostname required"}), 400 return jsonify({"error": "label, hostname required"}), 400
tags = None
if "tags" in body:
tags = _parse_host_tags(body.get("tags"))
if tags is None:
return jsonify({"error": "invalid tags"}), 400
# Validate identity or inline credentials # Validate identity or inline credentials
inline_auth_type = None inline_auth_type = None
@@ -1095,11 +1413,13 @@ def create_host():
inline_auth_type, inline_blob, inline_key_pass), inline_auth_type, inline_blob, inline_key_pass),
) )
hid = cur.lastrowid hid = cur.lastrowid
if tags is not None:
_set_host_tags(cur, int(hid), tags)
return jsonify({"id": hid}), 201 return jsonify({"id": hid}), 201
@app.route("/api/hosts/<int:hid>", methods=["PATCH"]) @app.route("/api/hosts/<int:hid>", methods=["PATCH"])
@require_login @require_auth("write:hosts")
def update_host(hid: int): def update_host(hid: int):
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
fields = [] fields = []
@@ -1185,21 +1505,34 @@ def update_host(hid: int):
return jsonify({"error": "folder not found"}), 400 return jsonify({"error": "folder not found"}), 400
fields.append("folder_id = %s") fields.append("folder_id = %s")
args.append(folder_id) args.append(folder_id)
if not fields:
tags = None
if "tags" in body:
tags = _parse_host_tags(body.get("tags"))
if tags is None:
return jsonify({"error": "invalid tags"}), 400
if not fields and tags is None:
return jsonify({"ok": True}) return jsonify({"ok": True})
args.append(hid)
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute( cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (hid,))
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s", if not cur.fetchone():
tuple(args),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
if fields:
update_args = list(args)
update_args.append(hid)
cur.execute(
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s",
tuple(update_args),
)
if tags is not None:
_set_host_tags(cur, hid, tags)
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.route("/api/hosts/<int:hid>", methods=["DELETE"]) @app.route("/api/hosts/<int:hid>", methods=["DELETE"])
@require_login @require_auth("write:hosts")
def delete_host(hid: int): def delete_host(hid: int):
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
cur.execute("DELETE FROM ssh_hosts WHERE id = %s", (hid,)) cur.execute("DELETE FROM ssh_hosts WHERE id = %s", (hid,))
@@ -1209,7 +1542,7 @@ def delete_host(hid: int):
@app.route("/api/audit/connections", methods=["GET"]) @app.route("/api/audit/connections", methods=["GET"])
@require_login @require_auth("read:audit")
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") raw_days = request.args.get("days_back")
@@ -1246,6 +1579,185 @@ def list_connection_audit():
return jsonify({"items": rows}) return jsonify({"items": rows})
@app.route("/api/api-keys/scopes", methods=["GET"])
@require_login
def list_api_key_scopes():
return jsonify(
{
"items": [
{
"id": "read:hosts",
"label": "Read hosts",
"description": "List hosts, folders, and identities",
},
{
"id": "write:hosts",
"label": "Write hosts",
"description": "Create, update, and delete hosts, folders, and identities",
},
{
"id": "read:audit",
"label": "Read audit",
"description": "View the connection audit log",
},
{
"id": "terminal:connect",
"label": "Terminal",
"description": "Open SSH terminal sessions (WebSocket)",
},
{
"id": "sftp:manage",
"label": "SFTP",
"description": "List, upload, download, and manage remote files",
},
]
}
)
def _serialize_api_key_row(row: dict[str, Any]) -> dict[str, Any]:
scopes = sorted(_parse_api_key_scopes(row.get("scopes")))
def _fmt_ts(val: Any) -> str | None:
if val is None:
return None
if hasattr(val, "isoformat"):
return val.isoformat(sep=" ", timespec="seconds")
return str(val)
expires_at = row.get("expires_at")
revoked_at = row.get("revoked_at")
expired = bool(
expires_at is not None and expires_at <= _utcnow() and revoked_at is None
)
return {
"id": row["id"],
"label": row["label"],
"key_prefix": row["key_prefix"],
"scopes": scopes,
"expires_at": _fmt_ts(expires_at),
"last_used_at": _fmt_ts(row.get("last_used_at")),
"revoked_at": _fmt_ts(revoked_at),
"created_at": _fmt_ts(row.get("created_at")),
"expired": expired,
"active": revoked_at is None and not expired,
}
@app.route("/api/api-keys", methods=["GET"])
@require_login
def list_api_keys():
with db_cursor() as (_, cur):
cur.execute(
"""
SELECT id, label, key_prefix, scopes, expires_at, last_used_at,
revoked_at, created_at
FROM api_keys
ORDER BY id DESC
"""
)
rows = cur.fetchall()
return jsonify({"items": [_serialize_api_key_row(r) for r in rows]})
@app.route("/api/api-keys", methods=["POST"])
@require_login
def create_api_key():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
if not label:
return jsonify({"error": "label required"}), 400
scopes = _validate_api_key_scopes(body.get("scopes"))
if scopes is None:
return jsonify({"error": "at least one valid scope required"}), 400
expires_at = _parse_optional_expires_at(body.get("expires_at"))
if expires_at == "invalid":
return jsonify({"error": "invalid expires_at"}), 400
if expires_at is not None and expires_at <= _utcnow():
return jsonify({"error": "expires_at must be in the future"}), 400
full_key, key_prefix, key_hash = _generate_api_key_material()
with db_cursor() as (_, cur):
cur.execute(
"""
INSERT INTO api_keys (label, key_prefix, key_hash, scopes, expires_at)
VALUES (%s, %s, %s, %s, %s)
""",
(label, key_prefix, key_hash, json.dumps(scopes), expires_at),
)
key_id = cur.lastrowid
return (
jsonify(
{
"id": key_id,
"label": label,
"key_prefix": key_prefix,
"scopes": scopes,
"expires_at": expires_at.isoformat(sep=" ", timespec="seconds")
if expires_at
else None,
"key": full_key,
}
),
201,
)
@app.route("/api/api-keys/<int:kid>", methods=["PATCH"])
@require_login
def update_api_key(kid: int):
body = request.get_json(silent=True) or {}
sets: list[str] = []
params: list[Any] = []
if "label" in body:
label = (body.get("label") or "").strip()
if not label:
return jsonify({"error": "label required"}), 400
sets.append("label = %s")
params.append(label)
if "scopes" in body:
scopes = _validate_api_key_scopes(body.get("scopes"))
if scopes is None:
return jsonify({"error": "at least one valid scope required"}), 400
sets.append("scopes = %s")
params.append(json.dumps(scopes))
if "expires_at" in body:
expires_at = _parse_optional_expires_at(body.get("expires_at"))
if expires_at == "invalid":
return jsonify({"error": "invalid expires_at"}), 400
if expires_at is not None and expires_at <= _utcnow():
return jsonify({"error": "expires_at must be in the future"}), 400
sets.append("expires_at = %s")
params.append(expires_at)
if not sets:
return jsonify({"error": "no changes"}), 400
params.append(kid)
with db_cursor() as (_, cur):
cur.execute(
f"UPDATE api_keys SET {', '.join(sets)} WHERE id = %s AND revoked_at IS NULL",
tuple(params),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.route("/api/api-keys/<int:kid>", methods=["DELETE"])
@require_login
def revoke_api_key(kid: int):
with db_cursor() as (_, cur):
cur.execute(
"""
UPDATE api_keys
SET revoked_at = CURRENT_TIMESTAMP
WHERE id = %s AND revoked_at IS NULL
""",
(kid,),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.route("/ws/terminal", websocket=True) @app.route("/ws/terminal", websocket=True)
def ws_terminal(): def ws_terminal():
sock, use_gevent_wsgi = _open_terminal_socket() sock, use_gevent_wsgi = _open_terminal_socket()
@@ -1257,7 +1769,8 @@ def ws_terminal():
pass pass
return GeventWsAppResponse() if use_gevent_wsgi else Response(status=400) return GeventWsAppResponse() if use_gevent_wsgi else Response(status=400)
if not session.get("logged_in"): auth = _ws_resolve_auth()
if not auth or not _auth_has_scopes(auth, ("terminal:connect",)):
return bail_close(1008, "unauthorized") return bail_close(1008, "unauthorized")
host_id_raw = request.args.get("host_id") host_id_raw = request.args.get("host_id")
@@ -1343,8 +1856,8 @@ def ws_terminal():
height=int(o.get("rows", 40)), height=int(o.get("rows", 40)),
) )
return True return True
elif o.get("type") == "ping": if o.get("type") == "ping":
# Ping message to keep connection alive, ignore without sending to channel sock.send(json.dumps({"type": "pong"}))
return True return True
except (json.JSONDecodeError, TypeError, ValueError): except (json.JSONDecodeError, TypeError, ValueError):
pass pass
@@ -1393,6 +1906,7 @@ def ws_terminal():
) )
) )
last_ws_keepalive = time.monotonic()
while not stop.is_set(): while not stop.is_set():
drained_eof = False drained_eof = False
try: try:
@@ -1402,16 +1916,31 @@ def ws_terminal():
drained_eof = True drained_eof = True
break break
sock.send(item) sock.send(item)
last_ws_keepalive = time.monotonic()
except queue.Empty: except queue.Empty:
pass pass
if drained_eof: if drained_eof:
break break
now = time.monotonic()
if (
WS_KEEPALIVE_INTERVAL > 0
and now - last_ws_keepalive >= WS_KEEPALIVE_INTERVAL
):
if not _ssh_transports_alive(client, jump_clients):
break
try:
sock.send(json.dumps({"type": "keepalive"}))
except Exception:
break
last_ws_keepalive = now
msg = sock.receive(timeout=0.15) msg = sock.receive(timeout=0.15)
if msg is None: if msg is None:
if use_gevent_wsgi and getattr(sock, "_gw", None) is not None and sock._gw.closed: if use_gevent_wsgi and getattr(sock, "_gw", None) is not None and sock._gw.closed:
break break
continue continue
last_ws_keepalive = time.monotonic()
if not handle_ws_inbound(msg): if not handle_ws_inbound(msg):
break break
except ConnectionClosed: except ConnectionClosed:
@@ -1439,7 +1968,7 @@ def ws_terminal():
@app.route("/api/sftp/<cid>/list", methods=["POST"]) @app.route("/api/sftp/<cid>/list", methods=["POST"])
@require_login @require_auth("sftp:manage")
def sftp_list(cid: str): def sftp_list(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
@@ -1472,7 +2001,7 @@ def _is_dir_mode(mode: int) -> bool:
@app.route("/api/sftp/<cid>/mkdir", methods=["POST"]) @app.route("/api/sftp/<cid>/mkdir", methods=["POST"])
@require_login @require_auth("sftp:manage")
def sftp_mkdir(cid: str): def sftp_mkdir(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
@@ -1487,7 +2016,7 @@ def sftp_mkdir(cid: str):
@app.route("/api/sftp/<cid>/remove", methods=["POST"]) @app.route("/api/sftp/<cid>/remove", methods=["POST"])
@require_login @require_auth("sftp:manage")
def sftp_remove(cid: str): def sftp_remove(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
@@ -1509,7 +2038,7 @@ def sftp_remove(cid: str):
@app.route("/api/sftp/<cid>/rename", methods=["POST"]) @app.route("/api/sftp/<cid>/rename", methods=["POST"])
@require_login @require_auth("sftp:manage")
def sftp_rename(cid: str): def sftp_rename(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
@@ -1525,7 +2054,7 @@ def sftp_rename(cid: str):
@app.route("/api/sftp/<cid>/upload", methods=["POST"]) @app.route("/api/sftp/<cid>/upload", methods=["POST"])
@require_login @require_auth("sftp:manage")
def sftp_upload(cid: str): def sftp_upload(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
@@ -1546,7 +2075,7 @@ def sftp_upload(cid: str):
@app.route("/api/sftp/<cid>/download", methods=["GET"]) @app.route("/api/sftp/<cid>/download", methods=["GET"])
@require_login @require_auth("sftp:manage")
def sftp_download(cid: str): def sftp_download(cid: str):
entry = _conn_get(cid) entry = _conn_get(cid)
if not entry: if not entry:
+339 -4
View File
@@ -7,9 +7,12 @@ import {
type IdentityRow, type IdentityRow,
type FolderRow, type FolderRow,
type ConnectionAuditRow, type ConnectionAuditRow,
type ApiKeyRow,
type ApiKeyScopeDef,
} from "@/api"; } from "@/api";
import LoginForm from "@/components/LoginForm.vue"; import LoginForm from "@/components/LoginForm.vue";
import TabContent from "@/components/TabContent.vue"; import TabContent from "@/components/TabContent.vue";
import TagInput from "@/components/TagInput.vue";
interface TabItem { interface TabItem {
id: string; id: string;
@@ -23,6 +26,7 @@ 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[]>([]);
const allTags = ref<string[]>([]);
const browseFolders = ref<FolderRow[]>([]); const browseFolders = ref<FolderRow[]>([]);
const browseHosts = ref<HostRow[]>([]); const browseHosts = ref<HostRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]); const breadcrumb = ref<{ id: number; label: string }[]>([]);
@@ -44,10 +48,23 @@ const showHostForm = ref(false);
const showFolderForm = ref(false); const showFolderForm = ref(false);
const showEditHost = ref(false); const showEditHost = ref(false);
const showAuditLog = ref(false); const showAuditLog = ref(false);
const showApiKeys = 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 auditShowAll = ref(false);
const apiKeysLoading = ref(false);
const apiKeysErr = ref("");
const apiKeyRows = ref<ApiKeyRow[]>([]);
const apiKeyScopes = ref<ApiKeyScopeDef[]>([]);
const apiKeyForm = ref({
label: "",
scopes: [] as string[],
expires_at: "",
});
const apiKeyCreating = ref(false);
const apiKeyCreateErr = ref("");
const createdApiKey = ref("");
const deleteIdentityErr = ref(""); const deleteIdentityErr = ref("");
const deleteIdentityErrId = ref<number | null>(null); const deleteIdentityErrId = ref<number | null>(null);
const newFolderLabel = ref(""); const newFolderLabel = ref("");
@@ -78,6 +95,7 @@ const hostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: 0 as number, identity_id: 0 as number,
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -92,6 +110,7 @@ const editHostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: 0, identity_id: 0,
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -119,6 +138,18 @@ function folderOptionLabel(id: number | null): string {
return parts.join(" / ") || `#${id}`; return parts.join(" / ") || `#${id}`;
} }
function parseTagsInput(raw: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of raw.split(",")) {
const name = part.trim().toLowerCase().replace(/\s+/g, "");
if (!name || seen.has(name)) continue;
seen.add(name);
tags.push(name);
}
return tags.sort();
}
function getSortedBrowseHosts(): HostRow[] { function getSortedBrowseHosts(): HostRow[] {
const hosts = [...browseHosts.value]; const hosts = [...browseHosts.value];
if (hostSortOrder.value === "last_connected") { if (hostSortOrder.value === "last_connected") {
@@ -158,12 +189,18 @@ function goToFolder(id: number | null) {
void refreshBrowse(); void refreshBrowse();
} }
function searchByTag(tag: string) {
searchQuery.value = `tag:${tag}`;
void refreshBrowse();
}
async function refreshData() { async function refreshData() {
loadErr.value = ""; loadErr.value = "";
try { try {
identities.value = await api.listIdentities(); identities.value = await api.listIdentities();
allHosts.value = await api.listHosts(); allHosts.value = await api.listHosts();
allFolders.value = await api.listFoldersFlat(); allFolders.value = await api.listFoldersFlat();
allTags.value = await api.listTags();
if (!hostForm.value.identity_id && identities.value.length) { if (!hostForm.value.identity_id && identities.value.length) {
hostForm.value.identity_id = identities.value[0].id; hostForm.value.identity_id = identities.value[0].id;
} }
@@ -244,6 +281,121 @@ async function loadAllAuditLog() {
} }
} }
function resetApiKeyForm() {
apiKeyForm.value = {
label: "",
scopes: apiKeyScopes.value.some((s) => s.id === "read:hosts")
? ["read:hosts"]
: [],
expires_at: "",
};
apiKeyCreateErr.value = "";
createdApiKey.value = "";
}
function toggleApiKeyScope(scopeId: string) {
const scopes = apiKeyForm.value.scopes;
const idx = scopes.indexOf(scopeId);
if (idx >= 0) {
apiKeyForm.value.scopes = scopes.filter((s) => s !== scopeId);
} else {
apiKeyForm.value.scopes = [...scopes, scopeId];
}
}
async function openApiKeys() {
showApiKeys.value = true;
apiKeysLoading.value = true;
apiKeysErr.value = "";
resetApiKeyForm();
try {
if (!apiKeyScopes.value.length) {
apiKeyScopes.value = await api.listApiKeyScopes();
resetApiKeyForm();
}
apiKeyRows.value = await api.listApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to load API keys";
} finally {
apiKeysLoading.value = false;
}
}
async function refreshApiKeys() {
apiKeysLoading.value = true;
apiKeysErr.value = "";
try {
apiKeyRows.value = await api.listApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to load API keys";
} finally {
apiKeysLoading.value = false;
}
}
async function submitApiKey() {
apiKeyCreating.value = true;
apiKeyCreateErr.value = "";
createdApiKey.value = "";
try {
const body: {
label: string;
scopes: string[];
expires_at?: string | null;
} = {
label: apiKeyForm.value.label.trim(),
scopes: apiKeyForm.value.scopes,
};
if (apiKeyForm.value.expires_at) {
body.expires_at = new Date(apiKeyForm.value.expires_at).toISOString();
}
const created = await api.createApiKey(body);
createdApiKey.value = created.key;
apiKeyForm.value = {
label: "",
scopes: apiKeyScopes.value.some((s) => s.id === "read:hosts")
? ["read:hosts"]
: [],
expires_at: "",
};
await refreshApiKeys();
} catch (e) {
apiKeyCreateErr.value = e instanceof Error ? e.message : "Failed to create API key";
} finally {
apiKeyCreating.value = false;
}
}
async function revokeApiKey(id: number, label: string) {
if (!confirm(`Revoke API key "${label}"? This cannot be undone.`)) return;
apiKeysErr.value = "";
try {
await api.revokeApiKey(id);
await refreshApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to revoke API key";
}
}
async function copyCreatedApiKey() {
if (!createdApiKey.value) return;
try {
await navigator.clipboard.writeText(createdApiKey.value);
} catch {
/* clipboard may be unavailable */
}
}
function apiKeyStatus(row: ApiKeyRow): string {
if (row.revoked_at) return "Revoked";
if (row.expired) return "Expired";
return "Active";
}
function fmtScopes(scopes: string[]): string {
return scopes.join(", ");
}
function openTab(h: HostRow) { function openTab(h: HostRow) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
tabs.value.push({ id, hostId: h.id, label: h.label }); tabs.value.push({ id, hostId: h.id, label: h.label });
@@ -358,6 +510,11 @@ async function submitHost() {
} else { } else {
body.identity_id = f.identity_id; body.identity_id = f.identity_id;
} }
const tags = parseTagsInput(f.tags);
if (tags.length) {
body.tags = tags;
}
await api.createHost(body); await api.createHost(body);
showHostForm.value = false; showHostForm.value = false;
@@ -365,6 +522,7 @@ async function submitHost() {
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: hostForm.value.identity_id, identity_id: hostForm.value.identity_id,
auth_type: "password", auth_type: "password",
@@ -412,6 +570,7 @@ function openEditHost(h: HostRow) {
label: h.label, label: h.label,
hostname: h.hostname, hostname: h.hostname,
port: h.port, port: h.port,
tags: (h.tags || []).join(", "),
use_inline_identity: hasInlineIdentity, use_inline_identity: hasInlineIdentity,
identity_id: h.identity_id || 0, identity_id: h.identity_id || 0,
auth_type: (h.identity_auth_type as "password" | "publickey") || "password", auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
@@ -450,6 +609,8 @@ async function submitEditHost() {
} else { } else {
body.identity_id = f.identity_id; body.identity_id = f.identity_id;
} }
body.tags = parseTagsInput(f.tags);
await api.patchHost(f.id, body); await api.patchHost(f.id, body);
showEditHost.value = false; showEditHost.value = false;
@@ -542,6 +703,13 @@ async function deleteIdentityRow(id: number) {
</a> </a>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="openApiKeys"
>
API keys
</button>
<button <button
type="button" type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white" class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@@ -581,7 +749,7 @@ async function deleteIdentityRow(id: number) {
<input <input
v-model="searchQuery" v-model="searchQuery"
type="search" type="search"
placeholder="Search in this folder…" placeholder="Search…"
class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
@input="onSearchInput" @input="onSearchInput"
> >
@@ -591,9 +759,11 @@ async function deleteIdentityRow(id: number) {
> >
Matches in Matches in
{{ {{
currentFolderId == null searchQuery.trim().toLowerCase().startsWith("tag:")
? "all folders" ? "all folders (tag search)"
: "this folder and below" : currentFolderId == null
? "all folders"
: "this folder and below"
}} }}
</p> </p>
<nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400"> <nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400">
@@ -709,6 +879,21 @@ async function deleteIdentityRow(id: number) {
> >
via {{ h.jump_host_label || `#${h.jump_host_id}` }} via {{ h.jump_host_label || `#${h.jump_host_id}` }}
</p> </p>
<div
v-if="h.tags?.length"
class="mt-1 flex flex-wrap gap-1"
>
<button
v-for="tag in h.tags"
:key="tag"
type="button"
class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400 hover:bg-slate-700 hover:text-accent"
:title="`Search tag:${tag}`"
@click.stop="searchByTag(tag)"
>
{{ tag }}
</button>
</div>
<p <p
v-if="searchActive" v-if="searchActive"
class="mt-0.5 truncate text-[10px] text-slate-600" class="mt-0.5 truncate text-[10px] text-slate-600"
@@ -836,6 +1021,144 @@ async function deleteIdentityRow(id: number) {
</main> </main>
</div> </div>
<div
v-if="showApiKeys"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<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">API keys</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="showApiKeys = false"
>
Close
</button>
</div>
<p class="mt-1 text-xs text-slate-500">
Create keys for external systems. Send
<code class="text-slate-400">Authorization: Bearer &lt;key&gt;</code>
on API requests. For WebSocket terminals, append
<code class="text-slate-400">?token=&lt;key&gt;</code>.
</p>
<form class="mt-5 rounded-lg border border-slate-800 bg-surface-overlay/40 p-4" @submit.prevent="submitApiKey">
<h3 class="text-sm font-medium text-white">Create key</h3>
<label class="mt-3 block text-xs uppercase text-slate-500">Label</label>
<input
v-model="apiKeyForm.label"
required
maxlength="255"
placeholder="CI deploy, monitoring, …"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<p class="mt-3 text-xs uppercase text-slate-500">Scopes</p>
<div class="mt-2 space-y-2">
<label
v-for="scope in apiKeyScopes"
:key="scope.id"
class="flex cursor-pointer items-start gap-2 rounded border border-slate-800 px-3 py-2 hover:border-slate-700"
>
<input
type="checkbox"
class="mt-0.5"
:checked="apiKeyForm.scopes.includes(scope.id)"
@change="toggleApiKeyScope(scope.id)"
/>
<span>
<span class="block text-sm text-slate-200">{{ scope.label }}</span>
<span class="block text-xs text-slate-500">{{ scope.description }}</span>
</span>
</label>
</div>
<label class="mt-3 block text-xs uppercase text-slate-500">Expiry (optional)</label>
<input
v-model="apiKeyForm.expires_at"
type="datetime-local"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<p v-if="apiKeyCreateErr" class="mt-3 text-xs text-red-400">{{ apiKeyCreateErr }}</p>
<div
v-if="createdApiKey"
class="mt-3 rounded border border-amber-900/60 bg-amber-950/30 p-3"
>
<p class="text-xs text-amber-200">
Copy this key now it will not be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code class="min-w-0 flex-1 break-all rounded bg-surface-overlay px-2 py-1 text-[11px] text-slate-200">
{{ createdApiKey }}
</code>
<button
type="button"
class="shrink-0 rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="copyCreatedApiKey"
>
Copy
</button>
</div>
</div>
<button
type="submit"
class="mt-4 rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-slate-950 hover:bg-sky-400 disabled:opacity-50"
:disabled="apiKeyCreating || !apiKeyForm.scopes.length"
>
{{ apiKeyCreating ? "Creating…" : "Create key" }}
</button>
</form>
<p v-if="apiKeysErr" class="mt-4 text-xs text-red-400">{{ apiKeysErr }}</p>
<p v-else-if="apiKeysLoading" class="mt-4 text-xs text-slate-400">Loading</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="min-w-full text-left text-xs">
<thead class="text-slate-500">
<tr class="border-b border-slate-800">
<th class="px-2 py-2 font-medium">Label</th>
<th class="px-2 py-2 font-medium">Prefix</th>
<th class="px-2 py-2 font-medium">Scopes</th>
<th class="px-2 py-2 font-medium">Expires</th>
<th class="px-2 py-2 font-medium">Last used</th>
<th class="px-2 py-2 font-medium">Status</th>
<th class="px-2 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
<tr
v-for="row in apiKeyRows"
:key="row.id"
class="border-b border-slate-900/80 text-slate-300"
>
<td class="px-2 py-2">{{ row.label }}</td>
<td class="px-2 py-2 font-mono text-[11px]">{{ row.key_prefix }}</td>
<td class="px-2 py-2">{{ fmtScopes(row.scopes) }}</td>
<td class="px-2 py-2">{{ row.expires_at ? fmtDate(row.expires_at) : "Never" }}</td>
<td class="px-2 py-2">{{ row.last_used_at ? fmtDate(row.last_used_at) : "Never" }}</td>
<td class="px-2 py-2">{{ apiKeyStatus(row) }}</td>
<td class="px-2 py-2 text-right">
<button
v-if="row.active"
type="button"
class="rounded px-2 py-1 text-red-400 hover:bg-slate-800"
@click="revokeApiKey(row.id, row.label)"
>
Revoke
</button>
</td>
</tr>
<tr v-if="!apiKeyRows.length">
<td class="px-2 py-4 text-center text-slate-500" colspan="7">
No API keys yet.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<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"
@@ -1088,6 +1411,12 @@ 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">Tags</label>
<TagInput v-model="hostForm.tags" :suggestions="allTags" />
<p class="mt-1 text-[10px] text-slate-500">
Comma-separated. Search with
<code class="text-slate-400">tag:name</code>.
</p>
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label> <label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
<div class="mt-1 flex gap-2"> <div class="mt-1 flex gap-2">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -1299,6 +1628,12 @@ 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">Tags</label>
<TagInput v-model="editHostForm.tags" :suggestions="allTags" />
<p class="mt-1 text-[10px] text-slate-500">
Comma-separated. Search with
<code class="text-slate-400">tag:name</code>.
</p>
<label class="mt-3 block text-xs uppercase text-slate-500">Folder</label> <label class="mt-3 block text-xs uppercase text-slate-500">Folder</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"
+69
View File
@@ -65,6 +65,12 @@ export const api = {
return d.items; return d.items;
}, },
async listTags(): Promise<string[]> {
const res = await fetch("/api/tags", { credentials: "include" });
const d = await handle<{ items: string[] }>(res);
return d.items;
},
async listFoldersFlat(): Promise<FolderRow[]> { async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" }); const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res); const d = await handle<{ items: FolderRow[] }>(res);
@@ -194,6 +200,40 @@ export const api = {
return d.items; return d.items;
}, },
async listApiKeyScopes(): Promise<ApiKeyScopeDef[]> {
const res = await fetch("/api/api-keys/scopes", { credentials: "include" });
const d = await handle<{ items: ApiKeyScopeDef[] }>(res);
return d.items;
},
async listApiKeys(): Promise<ApiKeyRow[]> {
const res = await fetch("/api/api-keys", { credentials: "include" });
const d = await handle<{ items: ApiKeyRow[] }>(res);
return d.items;
},
async createApiKey(body: {
label: string;
scopes: string[];
expires_at?: string | null;
}): Promise<CreateApiKeyResponse> {
const res = await fetch("/api/api-keys", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async revokeApiKey(id: number): Promise<void> {
const res = await fetch(`/api/api-keys/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async sftpList( async sftpList(
connId: string, connId: string,
path: string, path: string,
@@ -278,6 +318,7 @@ export interface HostRow {
identity_auth_type: string; identity_auth_type: string;
folder_label?: string | null; folder_label?: string | null;
last_connected_at?: string | null; last_connected_at?: string | null;
tags?: string[];
} }
export interface IdentityRow { export interface IdentityRow {
@@ -304,3 +345,31 @@ export interface ConnectionAuditRow {
ended_at: string | null; ended_at: string | null;
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ApiKeyScopeDef {
id: string;
label: string;
description: string;
}
export interface ApiKeyRow {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
expired: boolean;
active: boolean;
}
export interface CreateApiKeyResponse {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
key: string;
}
+39 -26
View File
@@ -24,7 +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; let visibilityHandler: (() => void) | 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:";
@@ -37,6 +37,12 @@ function sendResize() {
ws.send(JSON.stringify({ type: "resize", ...dims })); ws.send(JSON.stringify({ type: "resize", ...dims }));
} }
function sendPing() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}
function fitAndResize() { function fitAndResize() {
if (!fit || !term || !props.visible) return; if (!fit || !term || !props.visible) return;
try { try {
@@ -47,6 +53,25 @@ function fitAndResize() {
} }
} }
function isControlMessage(raw: string): boolean {
try {
const o = JSON.parse(raw) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term?.focus();
return true;
}
if (o.type === "keepalive" || o.type === "pong") {
return true;
}
} catch {
/* not JSON control traffic */
}
return false;
}
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
if (!termEl.value) return; if (!termEl.value) return;
@@ -87,29 +112,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) => {
if (!term) return; if (!term) return;
if (typeof ev.data === "string") { if (typeof ev.data === "string") {
try { if (isControlMessage(ev.data)) return;
const o = JSON.parse(ev.data) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term.focus();
return;
}
} catch {
/* fall through */
}
term.write(ev.data); term.write(ev.data);
return; return;
} }
@@ -122,22 +130,26 @@ 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 {
status.value = "Session ended"; status.value = "Session ended";
} }
}; };
visibilityHandler = () => {
if (document.visibilityState === "visible") {
sendPing();
fitAndResize();
}
};
document.addEventListener("visibilitychange", visibilityHandler);
}); });
onUnmounted(() => { onUnmounted(() => {
if (pingInterval) { if (visibilityHandler) {
clearInterval(pingInterval); document.removeEventListener("visibilitychange", visibilityHandler);
pingInterval = null; visibilityHandler = null;
} }
ro?.disconnect(); ro?.disconnect();
ro = null; ro = null;
@@ -155,6 +167,7 @@ watch(
await nextTick(); await nextTick();
fitAndResize(); fitAndResize();
term?.focus(); term?.focus();
sendPing();
} }
}, },
); );
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
modelValue: string;
suggestions: string[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const focused = ref(false);
function normalizeTag(raw: string): string {
return raw.trim().toLowerCase().replace(/\s+/g, "");
}
function selectedTags(): Set<string> {
return new Set(parseTagsInput(props.modelValue));
}
function parseTagsInput(raw: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of raw.split(",")) {
const name = normalizeTag(part);
if (!name || seen.has(name)) continue;
seen.add(name);
tags.push(name);
}
return tags;
}
function currentPartial(): string {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
return normalizeTag(idx >= 0 ? val.slice(idx + 1) : val);
}
const filteredSuggestions = computed(() => {
const partial = currentPartial();
const selected = selectedTags();
return props.suggestions
.filter((tag) => {
if (selected.has(tag)) return false;
if (!partial) return true;
return tag.includes(partial);
})
.slice(0, 8);
});
const showSuggestions = computed(
() => focused.value && filteredSuggestions.value.length > 0,
);
function applySuggestion(tag: string) {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
const prefix = idx >= 0 ? `${val.slice(0, idx + 1)} ` : "";
emit("update:modelValue", `${prefix}${tag}, `);
}
function onBlur() {
window.setTimeout(() => {
focused.value = false;
}, 150);
}
</script>
<template>
<div class="relative">
<input
:value="modelValue"
placeholder="buildagents, prod"
autocomplete="off"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@focus="focused = true"
@blur="onBlur"
/>
<ul
v-if="showSuggestions"
class="absolute z-20 mt-1 max-h-40 w-full overflow-auto rounded-lg border border-slate-700 bg-surface-raised py-1 shadow-lg"
>
<li v-for="tag in filteredSuggestions" :key="tag">
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
@mousedown.prevent="applySuggestion(tag)"
>
{{ tag }}
</button>
</li>
</ul>
</div>
</template>
Submodule jdbnet.co.uk deleted from 4c2697f274