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
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
CMD ["sleep", "infinity"]
+4 -1
View File
@@ -26,4 +26,7 @@ MYSQL_POOL_SIZE=5
MAX_CONCURRENT_SSH=32
# 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
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: 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: |
-1
View File
@@ -30,7 +30,6 @@ services:
ports:
- "5000:5000"
environment:
GEVENT_MONKEY_PATCH: "1"
MYSQL_HOST: "<YOUR_MYSQL_HOST>"
MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>"
MYSQL_USER: "<YOUR_MYSQL_USER>"
+570 -41
View File
@@ -15,14 +15,16 @@ import hashlib
import hmac
import io
import json
import secrets
import logging
import posixpath
import queue
import re
import threading
import time
import uuid
from contextlib import contextmanager
from datetime import timedelta
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any
@@ -32,7 +34,7 @@ import paramiko
from cryptography.fernet import Fernet, InvalidToken
from dotenv import load_dotenv
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 simple_websocket import ConnectionClosed, Server
@@ -136,6 +138,32 @@ def init_db():
CONSTRAINT fk_audit_host FOREIGN KEY (host_id)
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):
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]:
cur.execute(
"""
@@ -337,6 +451,153 @@ def require_login(fn):
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()
_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"))
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):
@@ -534,9 +814,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
)
if SSH_KEEPALIVE_INTERVAL > 0:
transport = client.get_transport()
if transport is not None:
transport.set_keepalive(SSH_KEEPALIVE_INTERVAL)
_apply_ssh_keepalive(client)
chan = client.invoke_shell(term="xterm-256color", width=120, height=40)
chan.setblocking(True)
@@ -685,7 +963,7 @@ def api_me():
@app.route("/api/identities", methods=["GET"])
@require_login
@require_auth("read:hosts")
def list_identities():
with db_cursor() as (_, cur):
cur.execute(
@@ -696,7 +974,7 @@ def list_identities():
@app.route("/api/identities", methods=["POST"])
@require_login
@require_auth("write:hosts")
def create_identity():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
@@ -739,7 +1017,7 @@ def create_identity():
@app.route("/api/identities/<int:iid>", methods=["PATCH"])
@require_login
@require_auth("write:hosts")
def update_identity(iid: int):
body = request.get_json(silent=True) or {}
with db_cursor() as (_, cur):
@@ -805,7 +1083,7 @@ def update_identity(iid: int):
@app.route("/api/identities/<int:iid>", methods=["DELETE"])
@require_login
@require_auth("write:hosts")
def delete_identity(iid: int):
with db_cursor() as (_, cur):
# Check if identity is being used by any hosts
@@ -832,7 +1110,8 @@ def _host_select_sql(extra_where: str = "") -> str:
COALESCE(i.label, 'One-time') AS identity_label,
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
pf.label AS folder_label,
jh.label AS jump_host_label
jh.label AS jump_host_label,
{_host_tag_list_sql()}
FROM ssh_hosts h
LEFT JOIN ssh_identities i ON i.id = h.identity_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"])
@require_login
@require_auth("read:hosts")
def list_all_folders():
with db_cursor() as (_, cur):
cur.execute(
@@ -852,7 +1131,7 @@ def list_all_folders():
@app.route("/api/folders", methods=["POST"])
@require_login
@require_auth("write:hosts")
def create_folder():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
@@ -875,7 +1154,7 @@ def create_folder():
@app.route("/api/folders/<int:fid>", methods=["PATCH"])
@require_login
@require_auth("write:hosts")
def update_folder(fid: int):
body = request.get_json(silent=True) or {}
with db_cursor() as (_, cur):
@@ -915,7 +1194,7 @@ def update_folder(fid: int):
@app.route("/api/folders/<int:fid>", methods=["DELETE"])
@require_login
@require_auth("write:hosts")
def delete_folder(fid: int):
with db_cursor() as (_, cur):
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"])
@require_login
@require_auth("read:hosts")
def api_browse():
raw_fid = request.args.get("folder_id")
if raw_fid in (None, "", "root"):
@@ -936,8 +1215,7 @@ def api_browse():
except (TypeError, ValueError):
return jsonify({"error": "invalid folder_id"}), 400
q = (request.args.get("q") or "").strip()
esc = _like_escape(q) if q else ""
pat = f"%{esc}%" if q else ""
search_mode, search_term = _parse_search_query(q)
with db_cursor() as (_, cur):
breadcrumb: list[dict[str, Any]] = []
@@ -948,6 +1226,30 @@ def api_browse():
breadcrumb = _folder_breadcrumb_rows(cur, folder_id)
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:
cur.execute(
_host_select_sql(
@@ -956,7 +1258,7 @@ def api_browse():
+ " ORDER BY h.label",
(pat, pat),
)
hosts = cur.fetchall()
hosts = _serialize_host_rows(cur.fetchall())
else:
ids = _folder_subtree_ids(cur, folder_id)
if not ids:
@@ -971,13 +1273,14 @@ def api_browse():
+ " ORDER BY h.label",
(*ids, pat, pat),
)
hosts = cur.fetchall()
hosts = _serialize_host_rows(cur.fetchall())
return jsonify(
{
"breadcrumb": breadcrumb,
"folders": [],
"hosts": hosts,
"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",
(folder_id,),
)
hosts = cur.fetchall()
hosts = _serialize_host_rows(cur.fetchall())
return jsonify(
{
@@ -1012,16 +1315,25 @@ def api_browse():
@app.route("/api/hosts", methods=["GET"])
@require_login
@require_auth("read:hosts")
def list_hosts():
with db_cursor() as (_, cur):
cur.execute(_host_select_sql("") + " ORDER BY h.label")
rows = cur.fetchall()
rows = _serialize_host_rows(cur.fetchall())
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"])
@require_login
@require_auth("write:hosts")
def create_host():
body = request.get_json(silent=True) or {}
label = (body.get("label") or "").strip()
@@ -1035,6 +1347,12 @@ def create_host():
if not label or not hostname:
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
inline_auth_type = None
inline_blob = None
@@ -1095,11 +1413,13 @@ def create_host():
inline_auth_type, inline_blob, inline_key_pass),
)
hid = cur.lastrowid
if tags is not None:
_set_host_tags(cur, int(hid), tags)
return jsonify({"id": hid}), 201
@app.route("/api/hosts/<int:hid>", methods=["PATCH"])
@require_login
@require_auth("write:hosts")
def update_host(hid: int):
body = request.get_json(silent=True) or {}
fields = []
@@ -1185,21 +1505,34 @@ def update_host(hid: int):
return jsonify({"error": "folder not found"}), 400
fields.append("folder_id = %s")
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})
args.append(hid)
with db_cursor() as (_, cur):
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (hid,))
if not cur.fetchone():
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(args),
tuple(update_args),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
if tags is not None:
_set_host_tags(cur, hid, tags)
return jsonify({"ok": True})
@app.route("/api/hosts/<int:hid>", methods=["DELETE"])
@require_login
@require_auth("write:hosts")
def delete_host(hid: int):
with db_cursor() as (_, cur):
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"])
@require_login
@require_auth("read:audit")
def list_connection_audit():
raw_limit = request.args.get("limit") or "200"
raw_days = request.args.get("days_back")
@@ -1246,6 +1579,185 @@ def list_connection_audit():
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)
def ws_terminal():
sock, use_gevent_wsgi = _open_terminal_socket()
@@ -1257,7 +1769,8 @@ def ws_terminal():
pass
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")
host_id_raw = request.args.get("host_id")
@@ -1343,8 +1856,8 @@ def ws_terminal():
height=int(o.get("rows", 40)),
)
return True
elif o.get("type") == "ping":
# Ping message to keep connection alive, ignore without sending to channel
if o.get("type") == "ping":
sock.send(json.dumps({"type": "pong"}))
return True
except (json.JSONDecodeError, TypeError, ValueError):
pass
@@ -1393,6 +1906,7 @@ def ws_terminal():
)
)
last_ws_keepalive = time.monotonic()
while not stop.is_set():
drained_eof = False
try:
@@ -1402,16 +1916,31 @@ def ws_terminal():
drained_eof = True
break
sock.send(item)
last_ws_keepalive = time.monotonic()
except queue.Empty:
pass
if drained_eof:
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)
if msg is None:
if use_gevent_wsgi and getattr(sock, "_gw", None) is not None and sock._gw.closed:
break
continue
last_ws_keepalive = time.monotonic()
if not handle_ws_inbound(msg):
break
except ConnectionClosed:
@@ -1439,7 +1968,7 @@ def ws_terminal():
@app.route("/api/sftp/<cid>/list", methods=["POST"])
@require_login
@require_auth("sftp:manage")
def sftp_list(cid: str):
entry = _conn_get(cid)
if not entry:
@@ -1472,7 +2001,7 @@ def _is_dir_mode(mode: int) -> bool:
@app.route("/api/sftp/<cid>/mkdir", methods=["POST"])
@require_login
@require_auth("sftp:manage")
def sftp_mkdir(cid: str):
entry = _conn_get(cid)
if not entry:
@@ -1487,7 +2016,7 @@ def sftp_mkdir(cid: str):
@app.route("/api/sftp/<cid>/remove", methods=["POST"])
@require_login
@require_auth("sftp:manage")
def sftp_remove(cid: str):
entry = _conn_get(cid)
if not entry:
@@ -1509,7 +2038,7 @@ def sftp_remove(cid: str):
@app.route("/api/sftp/<cid>/rename", methods=["POST"])
@require_login
@require_auth("sftp:manage")
def sftp_rename(cid: str):
entry = _conn_get(cid)
if not entry:
@@ -1525,7 +2054,7 @@ def sftp_rename(cid: str):
@app.route("/api/sftp/<cid>/upload", methods=["POST"])
@require_login
@require_auth("sftp:manage")
def sftp_upload(cid: str):
entry = _conn_get(cid)
if not entry:
@@ -1546,7 +2075,7 @@ def sftp_upload(cid: str):
@app.route("/api/sftp/<cid>/download", methods=["GET"])
@require_login
@require_auth("sftp:manage")
def sftp_download(cid: str):
entry = _conn_get(cid)
if not entry:
+337 -2
View File
@@ -7,9 +7,12 @@ import {
type IdentityRow,
type FolderRow,
type ConnectionAuditRow,
type ApiKeyRow,
type ApiKeyScopeDef,
} from "@/api";
import LoginForm from "@/components/LoginForm.vue";
import TabContent from "@/components/TabContent.vue";
import TagInput from "@/components/TagInput.vue";
interface TabItem {
id: string;
@@ -23,6 +26,7 @@ const appVersion = ref("unknown");
const identities = ref<IdentityRow[]>([]);
const allHosts = ref<HostRow[]>([]);
const allFolders = ref<FolderRow[]>([]);
const allTags = ref<string[]>([]);
const browseFolders = ref<FolderRow[]>([]);
const browseHosts = ref<HostRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]);
@@ -44,10 +48,23 @@ const showHostForm = ref(false);
const showFolderForm = ref(false);
const showEditHost = ref(false);
const showAuditLog = ref(false);
const showApiKeys = ref(false);
const auditLoading = ref(false);
const auditErr = ref("");
const auditRows = ref<ConnectionAuditRow[]>([]);
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 deleteIdentityErrId = ref<number | null>(null);
const newFolderLabel = ref("");
@@ -78,6 +95,7 @@ const hostForm = ref({
label: "",
hostname: "",
port: 22,
tags: "",
use_inline_identity: false,
identity_id: 0 as number,
auth_type: "password" as "password" | "publickey",
@@ -92,6 +110,7 @@ const editHostForm = ref({
label: "",
hostname: "",
port: 22,
tags: "",
use_inline_identity: false,
identity_id: 0,
auth_type: "password" as "password" | "publickey",
@@ -119,6 +138,18 @@ function folderOptionLabel(id: number | null): string {
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[] {
const hosts = [...browseHosts.value];
if (hostSortOrder.value === "last_connected") {
@@ -158,12 +189,18 @@ function goToFolder(id: number | null) {
void refreshBrowse();
}
function searchByTag(tag: string) {
searchQuery.value = `tag:${tag}`;
void refreshBrowse();
}
async function refreshData() {
loadErr.value = "";
try {
identities.value = await api.listIdentities();
allHosts.value = await api.listHosts();
allFolders.value = await api.listFoldersFlat();
allTags.value = await api.listTags();
if (!hostForm.value.identity_id && identities.value.length) {
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) {
const id = crypto.randomUUID();
tabs.value.push({ id, hostId: h.id, label: h.label });
@@ -359,12 +511,18 @@ async function submitHost() {
body.identity_id = f.identity_id;
}
const tags = parseTagsInput(f.tags);
if (tags.length) {
body.tags = tags;
}
await api.createHost(body);
showHostForm.value = false;
hostForm.value = {
label: "",
hostname: "",
port: 22,
tags: "",
use_inline_identity: false,
identity_id: hostForm.value.identity_id,
auth_type: "password",
@@ -412,6 +570,7 @@ function openEditHost(h: HostRow) {
label: h.label,
hostname: h.hostname,
port: h.port,
tags: (h.tags || []).join(", "),
use_inline_identity: hasInlineIdentity,
identity_id: h.identity_id || 0,
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
@@ -451,6 +610,8 @@ async function submitEditHost() {
body.identity_id = f.identity_id;
}
body.tags = parseTagsInput(f.tags);
await api.patchHost(f.id, body);
showEditHost.value = false;
allHosts.value = await api.listHosts();
@@ -542,6 +703,13 @@ async function deleteIdentityRow(id: number) {
</a>
</div>
<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
type="button"
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
v-model="searchQuery"
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"
@input="onSearchInput"
>
@@ -591,7 +759,9 @@ async function deleteIdentityRow(id: number) {
>
Matches in
{{
currentFolderId == null
searchQuery.trim().toLowerCase().startsWith("tag:")
? "all folders (tag search)"
: currentFolderId == null
? "all folders"
: "this folder and below"
}}
@@ -709,6 +879,21 @@ async function deleteIdentityRow(id: number) {
>
via {{ h.jump_host_label || `#${h.jump_host_id}` }}
</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
v-if="searchActive"
class="mt-0.5 truncate text-[10px] text-slate-600"
@@ -836,6 +1021,144 @@ async function deleteIdentityRow(id: number) {
</main>
</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
v-if="showAuditLog"
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"
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>
<div class="mt-1 flex gap-2">
<label class="flex items-center gap-2 cursor-pointer">
@@ -1299,6 +1628,12 @@ async function deleteIdentityRow(id: number) {
max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<label class="mt-3 block text-xs uppercase text-slate-500">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>
<select
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;
},
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[]> {
const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res);
@@ -194,6 +200,40 @@ export const api = {
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(
connId: string,
path: string,
@@ -278,6 +318,7 @@ export interface HostRow {
identity_auth_type: string;
folder_label?: string | null;
last_connected_at?: string | null;
tags?: string[];
}
export interface IdentityRow {
@@ -304,3 +345,31 @@ export interface ConnectionAuditRow {
ended_at: string | 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 fit: FitAddon | null = null;
let ro: ResizeObserver | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let visibilityHandler: (() => void) | null = null;
function wsUrl(hostId: number): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -37,6 +37,12 @@ function sendResize() {
ws.send(JSON.stringify({ type: "resize", ...dims }));
}
function sendPing() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}
function fitAndResize() {
if (!fit || !term || !props.visible) return;
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 () => {
await nextTick();
if (!termEl.value) return;
@@ -87,29 +112,12 @@ onMounted(async () => {
ws.onopen = () => {
status.value = "Handshaking…";
sendResize();
// Send ping every 60 seconds to keep connection alive
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 60000);
};
ws.onmessage = (ev) => {
if (!term) return;
if (typeof ev.data === "string") {
try {
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 */
}
if (isControlMessage(ev.data)) return;
term.write(ev.data);
return;
}
@@ -122,22 +130,26 @@ onMounted(async () => {
};
ws.onclose = () => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (!connId.value) {
status.value = "Disconnected";
} else {
status.value = "Session ended";
}
};
visibilityHandler = () => {
if (document.visibilityState === "visible") {
sendPing();
fitAndResize();
}
};
document.addEventListener("visibilitychange", visibilityHandler);
});
onUnmounted(() => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
if (visibilityHandler) {
document.removeEventListener("visibilitychange", visibilityHandler);
visibilityHandler = null;
}
ro?.disconnect();
ro = null;
@@ -155,6 +167,7 @@ watch(
await nextTick();
fitAndResize();
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