feat: added api key support #13

Merged
jamie merged 4 commits from v1.1.0 into main 2026-05-23 16:42:43 +01:00
5 changed files with 710 additions and 28 deletions
Showing only changes of commit 0664d8763d - Show all commits
+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"]
+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: |
+363 -23
View File
@@ -15,6 +15,7 @@ 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
@@ -22,7 +23,7 @@ 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 +33,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 +137,18 @@ 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)
);
""" """
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
for stmt in ddl.split(";"): for stmt in ddl.split(";"):
@@ -337,6 +350,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]] = {}
@@ -685,7 +845,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 +856,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 +899,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 +965,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
@@ -841,7 +1001,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 +1012,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 +1035,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 +1075,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 +1085,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"):
@@ -1012,7 +1172,7 @@ 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")
@@ -1021,7 +1181,7 @@ def list_hosts():
@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()
@@ -1099,7 +1259,7 @@ def create_host():
@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 = []
@@ -1199,7 +1359,7 @@ def update_host(hid: int):
@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 +1369,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 +1406,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 +1596,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")
@@ -1439,7 +1779,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 +1812,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 +1827,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 +1849,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 +1865,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 +1886,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:
+275
View File
@@ -7,6 +7,8 @@ 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";
@@ -44,10 +46,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("");
@@ -244,6 +259,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 });
@@ -542,6 +672,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"
@@ -836,6 +973,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"
+62
View File
@@ -194,6 +194,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,
@@ -304,3 +338,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;
}