Compare commits
12 Commits
v1.0.0
...
4bdd4c1d8a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bdd4c1d8a | |||
| 336334c7f5 | |||
| a502ae2687 | |||
| 782d8446d9 | |||
| 5b79f5fb4b | |||
| a0f84ec78e | |||
| da996705c9 | |||
| 6d341309f1 | |||
| 187a3c7882 | |||
| c7ffdf81c2 | |||
| 0664d8763d | |||
| 853e06456e |
@@ -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
@@ -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
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -832,7 +1110,8 @@ def _host_select_sql(extra_where: str = "") -> str:
|
|||||||
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()
|
||||||
@@ -1035,6 +1347,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
|
||||||
inline_blob = None
|
inline_blob = 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("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(
|
cur.execute(
|
||||||
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s",
|
f"UPDATE ssh_hosts SET {', '.join(fields)} WHERE id = %s",
|
||||||
tuple(args),
|
tuple(update_args),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if tags is not None:
|
||||||
return jsonify({"error": "not found"}), 404
|
_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,184 @@ 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 delete_api_key(kid: int):
|
||||||
|
with db_cursor() as (_, cur):
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM api_keys
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(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 +1768,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 +1855,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 +1905,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 +1915,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 +1967,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 +2000,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 +2015,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 +2037,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 +2053,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 +2074,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:
|
||||||
|
|||||||
+342
-6
@@ -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 deleteApiKey(id: number, label: string) {
|
||||||
|
if (!confirm(`Delete API key "${label}"? This cannot be undone.`)) return;
|
||||||
|
apiKeysErr.value = "";
|
||||||
|
try {
|
||||||
|
await api.deleteApiKey(id);
|
||||||
|
await refreshApiKeys();
|
||||||
|
} catch (e) {
|
||||||
|
apiKeysErr.value = e instanceof Error ? e.message : "Failed to delete 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 });
|
||||||
@@ -359,12 +511,18 @@ async function submitHost() {
|
|||||||
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;
|
||||||
hostForm.value = {
|
hostForm.value = {
|
||||||
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",
|
||||||
@@ -451,6 +610,8 @@ async function submitEditHost() {
|
|||||||
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;
|
||||||
allHosts.value = await api.listHosts();
|
allHosts.value = await api.listHosts();
|
||||||
@@ -531,20 +692,29 @@ async function deleteIdentityRow(id: number) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex items-center gap-2 truncate">
|
||||||
|
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
||||||
<a
|
<a
|
||||||
href="https://git.jdbnet.co.uk/jamie/ssh"
|
href="https://git.jdbnet.co.uk/jamie/ssh"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex items-center gap-2 truncate"
|
class="truncate text-xs text-slate-400 hover:text-slate-300"
|
||||||
>
|
>
|
||||||
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
{{ appVersion }}
|
||||||
<span class="truncate text-xs text-slate-400 hover:text-slate-300">{{ appVersion }}</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<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="hidden rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white md:inline-flex"
|
||||||
|
@click="openApiKeys"
|
||||||
|
>
|
||||||
|
API keys
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hidden rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white md:inline-flex"
|
||||||
@click="openAuditLog"
|
@click="openAuditLog"
|
||||||
>
|
>
|
||||||
Connection audit
|
Connection audit
|
||||||
@@ -581,7 +751,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,7 +761,9 @@ async function deleteIdentityRow(id: number) {
|
|||||||
>
|
>
|
||||||
Matches in
|
Matches in
|
||||||
{{
|
{{
|
||||||
currentFolderId == null
|
searchQuery.trim().toLowerCase().startsWith("tag:")
|
||||||
|
? "all folders (tag search)"
|
||||||
|
: currentFolderId == null
|
||||||
? "all folders"
|
? "all folders"
|
||||||
: "this folder and below"
|
: "this folder and below"
|
||||||
}}
|
}}
|
||||||
@@ -709,6 +881,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 +1023,143 @@ 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 <key></code>
|
||||||
|
on API requests. For WebSocket terminals, append
|
||||||
|
<code class="text-slate-400">?token=<key></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
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 text-red-400 hover:bg-slate-800"
|
||||||
|
@click="deleteApiKey(row.id, row.label)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</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 +1412,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 +1629,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"
|
||||||
|
|||||||
@@ -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 deleteApiKey(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
-1
Submodule jdbnet.co.uk deleted from 4c2697f274
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ssh",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user