Compare commits
16 Commits
4e77b8f412
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cae073728a | |||
| 0f35d5bd6f | |||
| 1d6cce88a8 | |||
| 18e256baee | |||
| 7682a94981 | |||
| bb724377fe | |||
| 22a3dc7cbe | |||
| 6069f5395a | |||
| d542264567 | |||
| 7f717684eb | |||
| 90103f79c8 | |||
| 20db4742a7 | |||
| ca8b5dea7f | |||
| ca3d27e7f9 | |||
| 035f871b00 | |||
| 5bba2947c4 |
@@ -1,8 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,8 +15,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build -t cr.jdbnet.co.uk/public/ssh:latest .
|
docker build -t cr.jdbnet.co.uk/public/ssh:dev .
|
||||||
docker push cr.jdbnet.co.uk/public/ssh:latest
|
docker push cr.jdbnet.co.uk/public/ssh:dev
|
||||||
|
|
||||||
sonarqube:
|
sonarqube:
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
|
||||||
|
runs-on: build-htz-01
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract Version
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# - name: Generate Changelog
|
||||||
|
# id: changelog
|
||||||
|
# uses: https://github.com/metcalfc/changelog-generator@v4.6.2
|
||||||
|
# with:
|
||||||
|
# myToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
run: |
|
||||||
|
VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||||
|
docker build -t cr.jdbnet.co.uk/public/ssh:$VERSION \
|
||||||
|
-t cr.jdbnet.co.uk/public/ssh:latest \
|
||||||
|
--build-arg VERSION=$VERSION \
|
||||||
|
.
|
||||||
|
docker push cr.jdbnet.co.uk/public/ssh:$VERSION
|
||||||
|
docker push cr.jdbnet.co.uk/public/ssh:latest
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
name: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
body: ${{ steps.changelog.outputs.changelog }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
@@ -8,6 +8,8 @@ RUN npm run build
|
|||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
LABEL org.opencontainers.image.vendor="JDB-NET"
|
LABEL org.opencontainers.image.vendor="JDB-NET"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ENV VERSION=${VERSION}
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|||||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
||||||
days=int(os.getenv("SESSION_DAYS", "14"))
|
days=int(os.getenv("SESSION_DAYS", "14"))
|
||||||
)
|
)
|
||||||
|
app.config["VERSION"] = os.getenv("VERSION", "unknown")
|
||||||
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
|
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
|
||||||
app.config["SESSION_COOKIE_SECURE"] = True
|
app.config["SESSION_COOKIE_SECURE"] = True
|
||||||
|
|
||||||
@@ -111,11 +112,14 @@ def init_db():
|
|||||||
label VARCHAR(255) NOT NULL,
|
label VARCHAR(255) NOT NULL,
|
||||||
hostname VARCHAR(512) NOT NULL,
|
hostname VARCHAR(512) NOT NULL,
|
||||||
port INT NOT NULL DEFAULT 22,
|
port INT NOT NULL DEFAULT 22,
|
||||||
identity_id INT NOT NULL,
|
identity_id INT NULL,
|
||||||
|
inline_identity_auth_type ENUM('password','publickey') NULL,
|
||||||
|
inline_identity_encrypted_blob TEXT NULL,
|
||||||
|
inline_identity_encrypted_key_passphrase TEXT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id)
|
CONSTRAINT fk_host_identity FOREIGN KEY (identity_id)
|
||||||
REFERENCES ssh_identities(id) ON DELETE RESTRICT,
|
REFERENCES ssh_identities(id) ON DELETE SET NULL,
|
||||||
CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
|
CONSTRAINT fk_host_folder FOREIGN KEY (folder_id)
|
||||||
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
REFERENCES ssh_folders(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -139,6 +143,7 @@ def init_db():
|
|||||||
if s:
|
if s:
|
||||||
cur.execute(s)
|
cur.execute(s)
|
||||||
_ensure_jump_host_schema(cur)
|
_ensure_jump_host_schema(cur)
|
||||||
|
_ensure_inline_identity_schema(cur)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_jump_host_schema(cur) -> None:
|
def _ensure_jump_host_schema(cur) -> None:
|
||||||
@@ -164,6 +169,88 @@ def _ensure_jump_host_schema(cur) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_inline_identity_schema(cur) -> None:
|
||||||
|
"""Migrate existing databases to support inline (one-time) credentials."""
|
||||||
|
# Check and modify identity_id to allow NULL
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'identity_id'
|
||||||
|
AND IS_NULLABLE = 'NO'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is not None:
|
||||||
|
cur.execute("ALTER TABLE ssh_hosts MODIFY COLUMN identity_id INT NULL")
|
||||||
|
|
||||||
|
# Check and add inline_identity_auth_type column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_auth_type'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_auth_type ENUM('password','publickey') NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and add inline_identity_encrypted_blob column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_encrypted_blob'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_blob TEXT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and add inline_identity_encrypted_key_passphrase column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'inline_identity_encrypted_key_passphrase'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN inline_identity_encrypted_key_passphrase TEXT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check and add last_connected_at column
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'last_connected_at'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE ssh_hosts ADD COLUMN last_connected_at TIMESTAMP NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _like_escape(s: str) -> str:
|
def _like_escape(s: str) -> str:
|
||||||
return (
|
return (
|
||||||
s.replace("\\", "\\\\")
|
s.replace("\\", "\\\\")
|
||||||
@@ -370,7 +457,21 @@ def _registry_count() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]:
|
def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, paramiko.Channel]:
|
||||||
payload = decrypt_secret(host_row["encrypted_blob"])
|
# Prefer inline identity if available, otherwise use saved identity
|
||||||
|
if host_row.get("inline_identity_encrypted_blob"):
|
||||||
|
# Use inline credentials
|
||||||
|
auth_type = host_row["inline_identity_auth_type"]
|
||||||
|
encrypted_blob = host_row["inline_identity_encrypted_blob"]
|
||||||
|
encrypted_key_passphrase = host_row.get("inline_identity_encrypted_key_passphrase")
|
||||||
|
else:
|
||||||
|
# Use saved identity
|
||||||
|
if not host_row.get("encrypted_blob"):
|
||||||
|
raise ValueError("host has no identity configured")
|
||||||
|
auth_type = host_row["auth_type"]
|
||||||
|
encrypted_blob = host_row["encrypted_blob"]
|
||||||
|
encrypted_key_passphrase = host_row.get("encrypted_key_passphrase")
|
||||||
|
|
||||||
|
payload = decrypt_secret(encrypted_blob)
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
@@ -380,7 +481,7 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
|||||||
if not username_ssh:
|
if not username_ssh:
|
||||||
raise ValueError("identity payload missing ssh_username")
|
raise ValueError("identity payload missing ssh_username")
|
||||||
|
|
||||||
if host_row["auth_type"] == "password":
|
if auth_type == "password":
|
||||||
pwd = data.get("password")
|
pwd = data.get("password")
|
||||||
if not pwd:
|
if not pwd:
|
||||||
raise ValueError("missing password in identity")
|
raise ValueError("missing password in identity")
|
||||||
@@ -400,8 +501,8 @@ def _connect_paramiko(host_row: dict, sock=None) -> tuple[paramiko.SSHClient, pa
|
|||||||
raise ValueError("missing private_key in identity")
|
raise ValueError("missing private_key in identity")
|
||||||
pkey = None
|
pkey = None
|
||||||
key_pass = None
|
key_pass = None
|
||||||
if host_row.get("encrypted_key_passphrase"):
|
if encrypted_key_passphrase:
|
||||||
key_pass = decrypt_secret(host_row["encrypted_key_passphrase"]) or None
|
key_pass = decrypt_secret(encrypted_key_passphrase) or None
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
key_classes: list[type] = [
|
key_classes: list[type] = [
|
||||||
paramiko.RSAKey,
|
paramiko.RSAKey,
|
||||||
@@ -446,9 +547,10 @@ def _load_host_connect_row(cur, host_id: int) -> dict[str, Any] | None:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
SELECT h.id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||||
|
h.inline_identity_auth_type, h.inline_identity_encrypted_blob, h.inline_identity_encrypted_key_passphrase,
|
||||||
i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
|
i.auth_type, i.encrypted_blob, i.encrypted_key_passphrase
|
||||||
FROM ssh_hosts h
|
FROM ssh_hosts h
|
||||||
JOIN ssh_identities i ON i.id = h.identity_id
|
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||||
WHERE h.id = %s
|
WHERE h.id = %s
|
||||||
""",
|
""",
|
||||||
(host_id,),
|
(host_id,),
|
||||||
@@ -512,7 +614,13 @@ def _insert_connection_audit(host_row: dict[str, Any]) -> int | None:
|
|||||||
host_row.get("jump_host_id"),
|
host_row.get("jump_host_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return int(cur.lastrowid)
|
audit_id = int(cur.lastrowid)
|
||||||
|
# Update the host's last_connected_at timestamp
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE ssh_hosts SET last_connected_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
|
(int(host_row["id"]),),
|
||||||
|
)
|
||||||
|
return audit_id
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("failed to insert connection audit row")
|
log.exception("failed to insert connection audit row")
|
||||||
return None
|
return None
|
||||||
@@ -570,9 +678,10 @@ def api_logout():
|
|||||||
|
|
||||||
@app.route("/api/me", methods=["GET"])
|
@app.route("/api/me", methods=["GET"])
|
||||||
def api_me():
|
def api_me():
|
||||||
|
version = app.config.get("VERSION", "unknown")
|
||||||
if session.get("logged_in"):
|
if session.get("logged_in"):
|
||||||
return jsonify({"logged_in": True})
|
return jsonify({"logged_in": True, "app_version": version})
|
||||||
return jsonify({"logged_in": False})
|
return jsonify({"logged_in": False, "app_version": version})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/identities", methods=["GET"])
|
@app.route("/api/identities", methods=["GET"])
|
||||||
@@ -699,6 +808,17 @@ def update_identity(iid: int):
|
|||||||
@require_login
|
@require_login
|
||||||
def delete_identity(iid: int):
|
def delete_identity(iid: int):
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
|
# Check if identity is being used by any hosts
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) as count FROM ssh_hosts WHERE identity_id = %s",
|
||||||
|
(iid,),
|
||||||
|
)
|
||||||
|
result = cur.fetchone()
|
||||||
|
if result and result["count"] > 0:
|
||||||
|
return jsonify({
|
||||||
|
"error": f"Cannot delete identity: it is being used by {result['count']} host(s)"
|
||||||
|
}), 409
|
||||||
|
|
||||||
cur.execute("DELETE FROM ssh_identities WHERE id = %s", (iid,))
|
cur.execute("DELETE FROM ssh_identities WHERE id = %s", (iid,))
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
@@ -708,12 +828,13 @@ def delete_identity(iid: int):
|
|||||||
def _host_select_sql(extra_where: str = "") -> str:
|
def _host_select_sql(extra_where: str = "") -> str:
|
||||||
return f"""
|
return f"""
|
||||||
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
|
||||||
h.created_at, h.updated_at,
|
h.created_at, h.updated_at, h.last_connected_at,
|
||||||
i.label AS identity_label, i.auth_type AS identity_auth_type,
|
COALESCE(i.label, 'One-time') AS identity_label,
|
||||||
|
COALESCE(i.auth_type, h.inline_identity_auth_type) AS identity_auth_type,
|
||||||
pf.label AS folder_label,
|
pf.label AS folder_label,
|
||||||
jh.label AS jump_host_label
|
jh.label AS jump_host_label
|
||||||
FROM ssh_hosts h
|
FROM ssh_hosts h
|
||||||
JOIN ssh_identities i ON i.id = h.identity_id
|
LEFT JOIN ssh_identities i ON i.id = h.identity_id
|
||||||
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
LEFT JOIN ssh_folders pf ON pf.id = h.folder_id
|
||||||
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
|
LEFT JOIN ssh_hosts jh ON jh.id = h.jump_host_id
|
||||||
{extra_where}
|
{extra_where}
|
||||||
@@ -906,11 +1027,47 @@ def create_host():
|
|||||||
label = (body.get("label") or "").strip()
|
label = (body.get("label") or "").strip()
|
||||||
hostname = (body.get("hostname") or "").strip()
|
hostname = (body.get("hostname") or "").strip()
|
||||||
port = int(body.get("port") or 22)
|
port = int(body.get("port") or 22)
|
||||||
|
use_inline = body.get("use_inline_identity", False)
|
||||||
identity_id = body.get("identity_id")
|
identity_id = body.get("identity_id")
|
||||||
jump_host_raw = body.get("jump_host_id")
|
jump_host_raw = body.get("jump_host_id")
|
||||||
jump_host_id = int(jump_host_raw) if jump_host_raw is not None and jump_host_raw != "" else None
|
jump_host_id = int(jump_host_raw) if jump_host_raw is not None and jump_host_raw != "" else None
|
||||||
if not label or not hostname or not identity_id:
|
|
||||||
return jsonify({"error": "label, hostname, identity_id required"}), 400
|
if not label or not hostname:
|
||||||
|
return jsonify({"error": "label, hostname required"}), 400
|
||||||
|
|
||||||
|
# Validate identity or inline credentials
|
||||||
|
inline_auth_type = None
|
||||||
|
inline_blob = None
|
||||||
|
inline_key_pass = None
|
||||||
|
|
||||||
|
if use_inline:
|
||||||
|
# Use inline credentials
|
||||||
|
auth_type = body.get("auth_type")
|
||||||
|
ssh_username = (body.get("ssh_username") or "").strip()
|
||||||
|
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||||
|
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||||
|
|
||||||
|
if auth_type == "password":
|
||||||
|
password = body.get("password")
|
||||||
|
if not password:
|
||||||
|
return jsonify({"error": "password required"}), 400
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||||
|
else:
|
||||||
|
private_key = body.get("private_key")
|
||||||
|
if not private_key or not isinstance(private_key, str):
|
||||||
|
return jsonify({"error": "private_key required"}), 400
|
||||||
|
key_pass_plain = body.get("key_passphrase")
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||||
|
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||||
|
|
||||||
|
inline_auth_type = auth_type
|
||||||
|
inline_blob = encrypt_secret(payload)
|
||||||
|
identity_id = None
|
||||||
|
else:
|
||||||
|
# Use saved identity
|
||||||
|
if not identity_id:
|
||||||
|
return jsonify({"error": "identity_id required when not using inline identity"}), 400
|
||||||
|
|
||||||
fid = body.get("folder_id")
|
fid = body.get("folder_id")
|
||||||
folder_id = int(fid) if fid is not None and fid != "" else None
|
folder_id = int(fid) if fid is not None and fid != "" else None
|
||||||
if folder_id is not None:
|
if folder_id is not None:
|
||||||
@@ -918,17 +1075,24 @@ def create_host():
|
|||||||
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
cur.execute("SELECT id FROM ssh_folders WHERE id = %s", (folder_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
return jsonify({"error": "folder not found"}), 400
|
return jsonify({"error": "folder not found"}), 400
|
||||||
|
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
if jump_host_id is not None:
|
if jump_host_id is not None:
|
||||||
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
cur.execute("SELECT id FROM ssh_hosts WHERE id = %s", (jump_host_id,))
|
||||||
if not cur.fetchone():
|
if not cur.fetchone():
|
||||||
return jsonify({"error": "jump host not found"}), 400
|
return jsonify({"error": "jump host not found"}), 400
|
||||||
|
|
||||||
|
if identity_id:
|
||||||
|
identity_id = int(identity_id)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id)
|
INSERT INTO ssh_hosts (folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
inline_identity_auth_type, inline_identity_encrypted_blob, inline_identity_encrypted_key_passphrase)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(folder_id, label, hostname, port, int(identity_id), jump_host_id),
|
(folder_id, label, hostname, port, identity_id, jump_host_id,
|
||||||
|
inline_auth_type, inline_blob, inline_key_pass),
|
||||||
)
|
)
|
||||||
hid = cur.lastrowid
|
hid = cur.lastrowid
|
||||||
return jsonify({"id": hid}), 201
|
return jsonify({"id": hid}), 201
|
||||||
@@ -940,6 +1104,53 @@ def update_host(hid: int):
|
|||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
fields = []
|
fields = []
|
||||||
args: list[Any] = []
|
args: list[Any] = []
|
||||||
|
|
||||||
|
# Handle inline identity switching
|
||||||
|
if "use_inline_identity" in body:
|
||||||
|
use_inline = body.get("use_inline_identity", False)
|
||||||
|
if use_inline:
|
||||||
|
# Switch to inline credentials
|
||||||
|
auth_type = body.get("auth_type")
|
||||||
|
ssh_username = (body.get("ssh_username") or "").strip()
|
||||||
|
if not auth_type or auth_type not in ("password", "publickey") or not ssh_username:
|
||||||
|
return jsonify({"error": "auth_type and ssh_username required for inline identity"}), 400
|
||||||
|
|
||||||
|
if auth_type == "password":
|
||||||
|
password = body.get("password")
|
||||||
|
if not password:
|
||||||
|
return jsonify({"error": "password required"}), 400
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "password": password})
|
||||||
|
else:
|
||||||
|
private_key = body.get("private_key")
|
||||||
|
if not private_key or not isinstance(private_key, str):
|
||||||
|
return jsonify({"error": "private_key required"}), 400
|
||||||
|
key_pass_plain = body.get("key_passphrase")
|
||||||
|
payload = json.dumps({"ssh_username": ssh_username, "private_key": private_key})
|
||||||
|
inline_key_pass = encrypt_secret(key_pass_plain) if key_pass_plain else None
|
||||||
|
|
||||||
|
# Clear identity_id and set inline fields
|
||||||
|
fields.append("identity_id = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_auth_type = %s")
|
||||||
|
args.append(auth_type)
|
||||||
|
fields.append("inline_identity_encrypted_blob = %s")
|
||||||
|
args.append(encrypt_secret(payload))
|
||||||
|
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||||
|
if auth_type == "password":
|
||||||
|
args.append(None)
|
||||||
|
else:
|
||||||
|
args.append(inline_key_pass)
|
||||||
|
else:
|
||||||
|
# Switch to saved identity - clear inline fields
|
||||||
|
fields.append("identity_id = %s")
|
||||||
|
args.append(body.get("identity_id"))
|
||||||
|
fields.append("inline_identity_auth_type = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_encrypted_blob = %s")
|
||||||
|
args.append(None)
|
||||||
|
fields.append("inline_identity_encrypted_key_passphrase = %s")
|
||||||
|
args.append(None)
|
||||||
|
|
||||||
if "label" in body:
|
if "label" in body:
|
||||||
fields.append("label = %s")
|
fields.append("label = %s")
|
||||||
args.append(str(body["label"]).strip())
|
args.append(str(body["label"]).strip())
|
||||||
@@ -949,7 +1160,7 @@ def update_host(hid: int):
|
|||||||
if "port" in body:
|
if "port" in body:
|
||||||
fields.append("port = %s")
|
fields.append("port = %s")
|
||||||
args.append(int(body["port"]))
|
args.append(int(body["port"]))
|
||||||
if "identity_id" in body:
|
if "identity_id" in body and "use_inline_identity" not in body:
|
||||||
fields.append("identity_id = %s")
|
fields.append("identity_id = %s")
|
||||||
args.append(int(body["identity_id"]))
|
args.append(int(body["identity_id"]))
|
||||||
if "jump_host_id" in body:
|
if "jump_host_id" in body:
|
||||||
@@ -1001,22 +1212,36 @@ def delete_host(hid: int):
|
|||||||
@require_login
|
@require_login
|
||||||
def list_connection_audit():
|
def list_connection_audit():
|
||||||
raw_limit = request.args.get("limit") or "200"
|
raw_limit = request.args.get("limit") or "200"
|
||||||
|
raw_days = request.args.get("days_back")
|
||||||
try:
|
try:
|
||||||
limit = int(raw_limit)
|
limit = int(raw_limit)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
limit = 200
|
limit = 200
|
||||||
limit = max(1, min(limit, 500))
|
limit = max(1, min(limit, 500))
|
||||||
|
|
||||||
|
# Build the where clause for days filtering
|
||||||
|
where_clause = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if raw_days is not None:
|
||||||
|
try:
|
||||||
|
days = int(raw_days)
|
||||||
|
if days > 0:
|
||||||
|
where_clause = "WHERE started_at >= DATE_SUB(NOW(), INTERVAL %s DAY)"
|
||||||
|
params = [days]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
with db_cursor() as (_, cur):
|
with db_cursor() as (_, cur):
|
||||||
cur.execute(
|
query = f"""
|
||||||
"""
|
|
||||||
SELECT id, host_id, host_label, hostname, port, jump_host_id,
|
SELECT id, host_id, host_label, hostname, port, jump_host_id,
|
||||||
started_at, ended_at, duration_seconds
|
started_at, ended_at, duration_seconds
|
||||||
FROM ssh_connection_audit
|
FROM ssh_connection_audit
|
||||||
|
{where_clause}
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
"""
|
||||||
(limit,),
|
params.append(limit)
|
||||||
)
|
cur.execute(query, tuple(params))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return jsonify({"items": rows})
|
return jsonify({"items": rows})
|
||||||
|
|
||||||
@@ -1118,6 +1343,9 @@ def ws_terminal():
|
|||||||
height=int(o.get("rows", 40)),
|
height=int(o.get("rows", 40)),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
elif o.get("type") == "ping":
|
||||||
|
# Ping message to keep connection alive, ignore without sending to channel
|
||||||
|
return True
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
channel.send(msg.encode("utf-8"))
|
channel.send(msg.encode("utf-8"))
|
||||||
|
|||||||
+541
-67
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { Folder } from "lucide-vue-next";
|
import { Folder, Pencil, Trash2 } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type HostRow,
|
type HostRow,
|
||||||
@@ -19,6 +19,7 @@ interface TabItem {
|
|||||||
|
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
const checking = ref(true);
|
const checking = ref(true);
|
||||||
|
const appVersion = ref("unknown");
|
||||||
const identities = ref<IdentityRow[]>([]);
|
const identities = ref<IdentityRow[]>([]);
|
||||||
const allHosts = ref<HostRow[]>([]);
|
const allHosts = ref<HostRow[]>([]);
|
||||||
const allFolders = ref<FolderRow[]>([]);
|
const allFolders = ref<FolderRow[]>([]);
|
||||||
@@ -31,12 +32,14 @@ const searchQuery = ref("");
|
|||||||
const tabs = ref<TabItem[]>([]);
|
const tabs = ref<TabItem[]>([]);
|
||||||
const activeTabId = ref<string | null>(null);
|
const activeTabId = ref<string | null>(null);
|
||||||
const loadErr = ref("");
|
const loadErr = ref("");
|
||||||
|
const hostSortOrder = ref<"name" | "last_connected">("name");
|
||||||
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
|
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
|
||||||
const sidebarOpen = ref(true);
|
const sidebarOpen = ref(true);
|
||||||
|
|
||||||
let searchDebounceTimer = 0;
|
let searchDebounceTimer = 0;
|
||||||
|
|
||||||
const showIdentityForm = ref(false);
|
const showIdentityForm = ref(false);
|
||||||
|
const showEditIdentity = ref(false);
|
||||||
const showHostForm = ref(false);
|
const showHostForm = ref(false);
|
||||||
const showFolderForm = ref(false);
|
const showFolderForm = ref(false);
|
||||||
const showEditHost = ref(false);
|
const showEditHost = ref(false);
|
||||||
@@ -44,7 +47,16 @@ const showAuditLog = ref(false);
|
|||||||
const auditLoading = ref(false);
|
const auditLoading = ref(false);
|
||||||
const auditErr = ref("");
|
const auditErr = ref("");
|
||||||
const auditRows = ref<ConnectionAuditRow[]>([]);
|
const auditRows = ref<ConnectionAuditRow[]>([]);
|
||||||
|
const auditShowAll = ref(false);
|
||||||
|
const deleteIdentityErr = ref("");
|
||||||
|
const deleteIdentityErrId = ref<number | null>(null);
|
||||||
const newFolderLabel = ref("");
|
const newFolderLabel = ref("");
|
||||||
|
const showEditFolder = ref(false);
|
||||||
|
const editFolderForm = ref({
|
||||||
|
id: 0,
|
||||||
|
label: "",
|
||||||
|
parent_id: null as number | null,
|
||||||
|
});
|
||||||
const identityForm = ref({
|
const identityForm = ref({
|
||||||
label: "",
|
label: "",
|
||||||
auth_type: "password" as "password" | "publickey",
|
auth_type: "password" as "password" | "publickey",
|
||||||
@@ -53,11 +65,26 @@ const identityForm = ref({
|
|||||||
private_key: "",
|
private_key: "",
|
||||||
key_passphrase: "",
|
key_passphrase: "",
|
||||||
});
|
});
|
||||||
|
const editIdentityForm = ref({
|
||||||
|
id: 0,
|
||||||
|
label: "",
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
});
|
||||||
const hostForm = ref({
|
const hostForm = ref({
|
||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: 0 as number,
|
identity_id: 0 as number,
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
jump_host_id: null as number | null,
|
jump_host_id: null as number | null,
|
||||||
});
|
});
|
||||||
const editHostForm = ref({
|
const editHostForm = ref({
|
||||||
@@ -65,7 +92,13 @@ const editHostForm = ref({
|
|||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: 0,
|
identity_id: 0,
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
folder_id: null as number | null,
|
folder_id: null as number | null,
|
||||||
jump_host_id: null as number | null,
|
jump_host_id: null as number | null,
|
||||||
});
|
});
|
||||||
@@ -86,6 +119,20 @@ function folderOptionLabel(id: number | null): string {
|
|||||||
return parts.join(" / ") || `#${id}`;
|
return parts.join(" / ") || `#${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSortedBrowseHosts(): HostRow[] {
|
||||||
|
const hosts = [...browseHosts.value];
|
||||||
|
if (hostSortOrder.value === "last_connected") {
|
||||||
|
// Sort by last_connected_at descending (most recent first), with never-connected at end
|
||||||
|
return hosts.sort((a, b) => {
|
||||||
|
const aTime = a.last_connected_at ? new Date(a.last_connected_at).getTime() : 0;
|
||||||
|
const bTime = b.last_connected_at ? new Date(b.last_connected_at).getTime() : 0;
|
||||||
|
return bTime - aTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Sort alphabetically by label
|
||||||
|
return hosts.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshBrowse() {
|
async function refreshBrowse() {
|
||||||
try {
|
try {
|
||||||
const d = await api.browse(currentFolderId.value, searchQuery.value);
|
const d = await api.browse(currentFolderId.value, searchQuery.value);
|
||||||
@@ -130,6 +177,9 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
const m = await api.me();
|
const m = await api.me();
|
||||||
loggedIn.value = m.logged_in;
|
loggedIn.value = m.logged_in;
|
||||||
|
if (m.app_version) {
|
||||||
|
appVersion.value = m.app_version;
|
||||||
|
}
|
||||||
if (loggedIn.value) await refreshData();
|
if (loggedIn.value) await refreshData();
|
||||||
} catch {
|
} catch {
|
||||||
loggedIn.value = false;
|
loggedIn.value = false;
|
||||||
@@ -169,10 +219,24 @@ function fmtDuration(totalSeconds: number | null): string {
|
|||||||
|
|
||||||
async function openAuditLog() {
|
async function openAuditLog() {
|
||||||
showAuditLog.value = true;
|
showAuditLog.value = true;
|
||||||
|
auditShowAll.value = false;
|
||||||
auditLoading.value = true;
|
auditLoading.value = true;
|
||||||
auditErr.value = "";
|
auditErr.value = "";
|
||||||
try {
|
try {
|
||||||
auditRows.value = await api.listConnectionAudit(250);
|
auditRows.value = await api.listConnectionAudit(250, 7);
|
||||||
|
} catch (e) {
|
||||||
|
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllAuditLog() {
|
||||||
|
auditShowAll.value = true;
|
||||||
|
auditLoading.value = true;
|
||||||
|
auditErr.value = "";
|
||||||
|
try {
|
||||||
|
auditRows.value = await api.listConnectionAudit(500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -225,22 +289,89 @@ async function submitIdentity() {
|
|||||||
await refreshData();
|
await refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openEditIdentity(i: IdentityRow) {
|
||||||
|
editIdentityForm.value = {
|
||||||
|
id: i.id,
|
||||||
|
label: i.label,
|
||||||
|
auth_type: i.auth_type,
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
};
|
||||||
|
showEditIdentity.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditIdentity() {
|
||||||
|
const f = editIdentityForm.value;
|
||||||
|
const body: Partial<{
|
||||||
|
label: string;
|
||||||
|
ssh_username: string;
|
||||||
|
password: string;
|
||||||
|
private_key: string;
|
||||||
|
key_passphrase: string;
|
||||||
|
}> = {
|
||||||
|
label: f.label.trim(),
|
||||||
|
};
|
||||||
|
if (f.ssh_username.trim()) body.ssh_username = f.ssh_username.trim();
|
||||||
|
if (f.auth_type === "password" && f.password) body.password = f.password;
|
||||||
|
else if (f.auth_type === "publickey") {
|
||||||
|
if (f.private_key) body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase !== undefined) body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
await api.updateIdentity(f.id, body);
|
||||||
|
showEditIdentity.value = false;
|
||||||
|
editIdentityForm.value = {
|
||||||
|
id: 0,
|
||||||
|
label: "",
|
||||||
|
auth_type: "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
};
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
async function submitHost() {
|
async function submitHost() {
|
||||||
const f = hostForm.value;
|
const f = hostForm.value;
|
||||||
await api.createHost({
|
const body: Record<string, unknown> = {
|
||||||
label: f.label.trim(),
|
label: f.label.trim(),
|
||||||
hostname: f.hostname.trim(),
|
hostname: f.hostname.trim(),
|
||||||
port: Number(f.port) || 22,
|
port: Number(f.port) || 22,
|
||||||
identity_id: f.identity_id,
|
|
||||||
folder_id: currentFolderId.value,
|
folder_id: currentFolderId.value,
|
||||||
jump_host_id: f.jump_host_id,
|
jump_host_id: f.jump_host_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (f.use_inline_identity) {
|
||||||
|
body.use_inline_identity = true;
|
||||||
|
body.auth_type = f.auth_type;
|
||||||
|
body.ssh_username = f.ssh_username.trim();
|
||||||
|
if (f.auth_type === "password") {
|
||||||
|
body.password = f.password;
|
||||||
|
} else {
|
||||||
|
body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase) {
|
||||||
|
body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.identity_id = f.identity_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.createHost(body);
|
||||||
showHostForm.value = false;
|
showHostForm.value = false;
|
||||||
hostForm.value = {
|
hostForm.value = {
|
||||||
label: "",
|
label: "",
|
||||||
hostname: "",
|
hostname: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
|
use_inline_identity: false,
|
||||||
identity_id: hostForm.value.identity_id,
|
identity_id: hostForm.value.identity_id,
|
||||||
|
auth_type: "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
jump_host_id: null,
|
jump_host_id: null,
|
||||||
};
|
};
|
||||||
await refreshData();
|
await refreshData();
|
||||||
@@ -255,13 +386,39 @@ async function submitFolder() {
|
|||||||
await refreshBrowse();
|
await refreshBrowse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditFolder(f: FolderRow) {
|
||||||
|
editFolderForm.value = {
|
||||||
|
id: f.id,
|
||||||
|
label: f.label,
|
||||||
|
parent_id: f.parent_id,
|
||||||
|
};
|
||||||
|
showEditFolder.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditFolder() {
|
||||||
|
const f = editFolderForm.value;
|
||||||
|
await api.updateFolder(f.id, {
|
||||||
|
label: f.label.trim(),
|
||||||
|
parent_id: f.parent_id,
|
||||||
|
});
|
||||||
|
showEditFolder.value = false;
|
||||||
|
await refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
function openEditHost(h: HostRow) {
|
function openEditHost(h: HostRow) {
|
||||||
|
const hasInlineIdentity = h.identity_id === null;
|
||||||
editHostForm.value = {
|
editHostForm.value = {
|
||||||
id: h.id,
|
id: h.id,
|
||||||
label: h.label,
|
label: h.label,
|
||||||
hostname: h.hostname,
|
hostname: h.hostname,
|
||||||
port: h.port,
|
port: h.port,
|
||||||
identity_id: h.identity_id,
|
use_inline_identity: hasInlineIdentity,
|
||||||
|
identity_id: h.identity_id || 0,
|
||||||
|
auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
folder_id: h.folder_id,
|
folder_id: h.folder_id,
|
||||||
jump_host_id: h.jump_host_id,
|
jump_host_id: h.jump_host_id,
|
||||||
};
|
};
|
||||||
@@ -270,14 +427,31 @@ function openEditHost(h: HostRow) {
|
|||||||
|
|
||||||
async function submitEditHost() {
|
async function submitEditHost() {
|
||||||
const f = editHostForm.value;
|
const f = editHostForm.value;
|
||||||
await api.patchHost(f.id, {
|
const body: Record<string, unknown> = {
|
||||||
label: f.label.trim(),
|
label: f.label.trim(),
|
||||||
hostname: f.hostname.trim(),
|
hostname: f.hostname.trim(),
|
||||||
port: Number(f.port) || 22,
|
port: Number(f.port) || 22,
|
||||||
identity_id: f.identity_id,
|
|
||||||
folder_id: f.folder_id,
|
folder_id: f.folder_id,
|
||||||
jump_host_id: f.jump_host_id,
|
jump_host_id: f.jump_host_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (f.use_inline_identity) {
|
||||||
|
body.use_inline_identity = true;
|
||||||
|
body.auth_type = f.auth_type;
|
||||||
|
body.ssh_username = f.ssh_username.trim();
|
||||||
|
if (f.auth_type === "password") {
|
||||||
|
body.password = f.password;
|
||||||
|
} else {
|
||||||
|
body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase) {
|
||||||
|
body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.identity_id = f.identity_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.patchHost(f.id, body);
|
||||||
showEditHost.value = false;
|
showEditHost.value = false;
|
||||||
allHosts.value = await api.listHosts();
|
allHosts.value = await api.listHosts();
|
||||||
await refreshBrowse();
|
await refreshBrowse();
|
||||||
@@ -311,9 +485,15 @@ async function deleteHostRow(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteIdentityRow(id: number) {
|
async function deleteIdentityRow(id: number) {
|
||||||
if (!confirm("Remove this identity? Hosts using it may break.")) return;
|
if (!confirm("Remove this identity?")) return;
|
||||||
await api.deleteIdentity(id);
|
deleteIdentityErr.value = "";
|
||||||
await refreshData();
|
try {
|
||||||
|
await api.deleteIdentity(id);
|
||||||
|
await refreshData();
|
||||||
|
} catch (e) {
|
||||||
|
deleteIdentityErr.value = e instanceof Error ? e.message : "Failed to delete identity";
|
||||||
|
deleteIdentityErrId.value = id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -351,7 +531,15 @@ async function deleteIdentityRow(id: number) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
<a
|
||||||
|
href="https://git.jdbnet.co.uk/jamie/ssh"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-2 truncate"
|
||||||
|
>
|
||||||
|
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
||||||
|
<span class="truncate text-xs text-slate-400 hover:text-slate-300">{{ appVersion }}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -469,18 +657,39 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<span>{{ f.label }}</span>
|
<span>{{ f.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div class="flex gap-1 shrink-0">
|
||||||
type="button"
|
<button
|
||||||
class="shrink-0 text-[10px] text-red-400/80 hover:underline"
|
type="button"
|
||||||
@click="deleteFolderRow(f.id)"
|
class="text-slate-400/70 hover:text-slate-300"
|
||||||
>
|
title="Rename folder"
|
||||||
Del
|
@click="openEditFolder(f)"
|
||||||
</button>
|
>
|
||||||
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete folder"
|
||||||
|
@click="deleteFolderRow(f.id)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-if="browseHosts.length" class="mb-2 flex items-center justify-between">
|
||||||
|
<label class="text-xs text-slate-500 uppercase">Sort by:</label>
|
||||||
|
<select
|
||||||
|
v-model="hostSortOrder"
|
||||||
|
class="rounded border border-slate-700 bg-surface-overlay px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="last_connected">Last connected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li
|
<li
|
||||||
v-for="h in browseHosts"
|
v-for="h in getSortedBrowseHosts()"
|
||||||
:key="'h' + h.id"
|
:key="'h' + h.id"
|
||||||
class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
|
class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
|
||||||
>
|
>
|
||||||
@@ -506,20 +715,22 @@ async function deleteIdentityRow(id: number) {
|
|||||||
>
|
>
|
||||||
{{ folderOptionLabel(h.folder_id) }}
|
{{ folderOptionLabel(h.folder_id) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-[10px] text-slate-400 hover:text-white hover:underline"
|
class="text-slate-400/70 hover:text-slate-300"
|
||||||
|
title="Edit host"
|
||||||
@click="openEditHost(h)"
|
@click="openEditHost(h)"
|
||||||
>
|
>
|
||||||
Edit
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-[10px] text-red-400/80 hover:underline"
|
class="text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete host"
|
||||||
@click="deleteHostRow(h.id)"
|
@click="deleteHostRow(h.id)"
|
||||||
>
|
>
|
||||||
Remove
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -539,6 +750,16 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
Saved identities
|
Saved identities
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="deleteIdentityErr" class="mt-2 text-[11px] text-red-400">
|
||||||
|
{{ deleteIdentityErr }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 underline hover:text-red-300"
|
||||||
|
@click="deleteIdentityErr = ''"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
<ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
|
<ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
|
||||||
<li
|
<li
|
||||||
v-for="i in identities"
|
v-for="i in identities"
|
||||||
@@ -546,13 +767,24 @@ async function deleteIdentityRow(id: number) {
|
|||||||
class="flex items-center justify-between gap-1 py-0.5"
|
class="flex items-center justify-between gap-1 py-0.5"
|
||||||
>
|
>
|
||||||
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
|
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
|
||||||
<button
|
<div class="flex gap-1">
|
||||||
type="button"
|
<button
|
||||||
class="shrink-0 text-red-400/70 hover:underline"
|
type="button"
|
||||||
@click="deleteIdentityRow(i.id)"
|
class="shrink-0 text-slate-400/70 hover:text-slate-300"
|
||||||
>
|
title="Edit identity"
|
||||||
×
|
@click="openEditIdentity(i)"
|
||||||
</button>
|
>
|
||||||
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete identity"
|
||||||
|
@click="deleteIdentityRow(i.id)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,23 +839,32 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<div
|
<div
|
||||||
v-if="showAuditLog"
|
v-if="showAuditLog"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@click.self="showAuditLog = false"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 class="text-lg font-semibold text-white">Connection audit</h2>
|
<h2 class="text-lg font-semibold text-white">Connection audit</h2>
|
||||||
<button
|
<div class="flex gap-2">
|
||||||
type="button"
|
<button
|
||||||
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
v-if="!auditShowAll"
|
||||||
@click="showAuditLog = false"
|
type="button"
|
||||||
>
|
class="rounded-lg bg-slate-800 px-3 py-1.5 text-xs hover:bg-slate-700"
|
||||||
Close
|
@click="loadAllAuditLog"
|
||||||
</button>
|
>
|
||||||
|
Show all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click="showAuditLog = false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
Recent SSH sessions and how long they lasted.
|
Recent SSH sessions from the last 7 days and how long they lasted.
|
||||||
</p>
|
</p>
|
||||||
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</p>
|
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</p>
|
||||||
<p v-else-if="auditLoading" class="mt-3 text-xs text-slate-400">
|
<p v-else-if="auditLoading" class="mt-3 text-xs text-slate-400">
|
||||||
@@ -676,7 +917,6 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<div
|
<div
|
||||||
v-if="showIdentityForm"
|
v-if="showIdentityForm"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@click.self="showIdentityForm = false"
|
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
@@ -746,10 +986,79 @@ async function deleteIdentityRow(id: number) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showEditIdentity"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitEditIdentity"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Edit identity</h2>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="editIdentityForm.label"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Auth type</label>
|
||||||
|
<div class="mt-1 rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm text-slate-400">
|
||||||
|
{{ editIdentityForm.auth_type }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-[10px] text-slate-500">Auth type cannot be changed</p>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="editIdentityForm.ssh_username"
|
||||||
|
placeholder="Leave empty to keep unchanged"
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<template v-if="editIdentityForm.auth_type === 'password'">
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Password (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="editIdentityForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave empty to keep current password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Private key (PEM, optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editIdentityForm.private_key"
|
||||||
|
placeholder="Leave empty to keep current key"
|
||||||
|
rows="6"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="editIdentityForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave empty to remove passphrase"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
|
||||||
|
@click="showEditIdentity = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showHostForm"
|
v-if="showHostForm"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@click.self="showHostForm = false"
|
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
@@ -779,16 +1088,84 @@ async function deleteIdentityRow(id: number) {
|
|||||||
max="65535"
|
max="65535"
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||||
<select
|
<div class="mt-1 flex gap-2">
|
||||||
v-model.number="hostForm.identity_id"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
required
|
<input
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
type="radio"
|
||||||
>
|
:checked="!hostForm.use_inline_identity"
|
||||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
@change="hostForm.use_inline_identity = false"
|
||||||
{{ i.label }} ({{ i.auth_type }})
|
class="w-4 h-4"
|
||||||
</option>
|
/>
|
||||||
</select>
|
<span class="text-sm">Saved identity</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="hostForm.use_inline_identity"
|
||||||
|
@change="hostForm.use_inline_identity = true"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">One-time</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="!hostForm.use_inline_identity" class="mt-3">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||||
|
<select
|
||||||
|
v-model.number="hostForm.identity_id"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||||
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||||
|
<select
|
||||||
|
v-model="hostForm.auth_type"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="publickey">Public key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.ssh_username"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="hostForm.auth_type === 'password'">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||||
|
<textarea
|
||||||
|
v-model="hostForm.private_key"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
<select
|
<select
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
@@ -816,7 +1193,7 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
:disabled="!identities.length"
|
:disabled="!hostForm.use_inline_identity && !identities.length"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -827,7 +1204,6 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<div
|
<div
|
||||||
v-if="showFolderForm"
|
v-if="showFolderForm"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@click.self="showFolderForm = false"
|
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
@@ -861,10 +1237,42 @@ async function deleteIdentityRow(id: number) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showEditFolder"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitEditFolder"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Rename folder</h2>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="editFolderForm.label"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
|
||||||
|
@click="showEditFolder = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showEditHost"
|
v-if="showEditHost"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@click.self="showEditHost = false"
|
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="max-h-[90vh] w-full max-w-md overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
class="max-h-[90vh] w-full max-w-md overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
@@ -911,16 +1319,82 @@ async function deleteIdentityRow(id: number) {
|
|||||||
{{ folderOptionLabel(f.id) }}
|
{{ folderOptionLabel(f.id) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
|
||||||
<select
|
<div class="mt-1 flex gap-2">
|
||||||
v-model.number="editHostForm.identity_id"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
required
|
<input
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
type="radio"
|
||||||
>
|
:checked="!editHostForm.use_inline_identity"
|
||||||
<option v-for="i in identities" :key="i.id" :value="i.id">
|
@change="editHostForm.use_inline_identity = false"
|
||||||
{{ i.label }} ({{ i.auth_type }})
|
class="w-4 h-4"
|
||||||
</option>
|
/>
|
||||||
</select>
|
<span class="text-sm">Saved identity</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="editHostForm.use_inline_identity"
|
||||||
|
@change="editHostForm.use_inline_identity = true"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span class="text-sm">One-time</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="!editHostForm.use_inline_identity" class="mt-3">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Identity</label>
|
||||||
|
<select
|
||||||
|
v-model.number="editHostForm.identity_id"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||||
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Auth type</label>
|
||||||
|
<select
|
||||||
|
v-model="editHostForm.auth_type"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="publickey">Public key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.ssh_username"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="editHostForm.auth_type === 'password'">
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.password"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-xs uppercase text-slate-500">Private key</label>
|
||||||
|
<textarea
|
||||||
|
v-model="editHostForm.private_key"
|
||||||
|
rows="4"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm font-mono text-xs"
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
<label class="mt-2 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
<select
|
<select
|
||||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
|||||||
+43
-18
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
async me(): Promise<{ logged_in: boolean }> {
|
async me(): Promise<{ logged_in: boolean; app_version?: string }> {
|
||||||
const res = await fetch("/api/me", { credentials: "include" });
|
const res = await fetch("/api/me", { credentials: "include" });
|
||||||
return handle(res);
|
return handle(res);
|
||||||
},
|
},
|
||||||
@@ -92,20 +92,29 @@ export const api = {
|
|||||||
await handle(res);
|
await handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateFolder(
|
||||||
|
id: number,
|
||||||
|
body: {
|
||||||
|
label?: string;
|
||||||
|
parent_id?: number | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/folders/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
async listIdentities(): Promise<IdentityRow[]> {
|
async listIdentities(): Promise<IdentityRow[]> {
|
||||||
const res = await fetch("/api/identities", { credentials: "include" });
|
const res = await fetch("/api/identities", { credentials: "include" });
|
||||||
const d = await handle<{ items: IdentityRow[] }>(res);
|
const d = await handle<{ items: IdentityRow[] }>(res);
|
||||||
return d.items;
|
return d.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createHost(body: {
|
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
label: string;
|
|
||||||
hostname: string;
|
|
||||||
port?: number;
|
|
||||||
identity_id: number;
|
|
||||||
folder_id?: number | null;
|
|
||||||
jump_host_id?: number | null;
|
|
||||||
}): Promise<{ id: number }> {
|
|
||||||
const res = await fetch("/api/hosts", {
|
const res = await fetch("/api/hosts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -117,14 +126,7 @@ export const api = {
|
|||||||
|
|
||||||
async patchHost(
|
async patchHost(
|
||||||
id: number,
|
id: number,
|
||||||
body: Partial<{
|
body: Record<string, unknown>,
|
||||||
label: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
identity_id: number;
|
|
||||||
folder_id: number | null;
|
|
||||||
jump_host_id: number | null;
|
|
||||||
}>,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`/api/hosts/${id}`, {
|
const res = await fetch(`/api/hosts/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -153,6 +155,25 @@ export const api = {
|
|||||||
return handle(res);
|
return handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateIdentity(
|
||||||
|
id: number,
|
||||||
|
body: Partial<{
|
||||||
|
label: string;
|
||||||
|
ssh_username: string;
|
||||||
|
password: string;
|
||||||
|
private_key: string;
|
||||||
|
key_passphrase: string;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/identities/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteIdentity(id: number): Promise<void> {
|
async deleteIdentity(id: number): Promise<void> {
|
||||||
const res = await fetch(`/api/identities/${id}`, {
|
const res = await fetch(`/api/identities/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -161,8 +182,11 @@ export const api = {
|
|||||||
await handle(res);
|
await handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
|
async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
|
||||||
const q = new URLSearchParams({ limit: String(limit) });
|
const q = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (daysBack !== undefined) {
|
||||||
|
q.set("days_back", String(daysBack));
|
||||||
|
}
|
||||||
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@@ -253,6 +277,7 @@ export interface HostRow {
|
|||||||
identity_label: string;
|
identity_label: string;
|
||||||
identity_auth_type: string;
|
identity_auth_type: string;
|
||||||
folder_label?: string | null;
|
folder_label?: string | null;
|
||||||
|
last_connected_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentityRow {
|
export interface IdentityRow {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ let ws: WebSocket | null = null;
|
|||||||
let term: Terminal | null = null;
|
let term: Terminal | null = null;
|
||||||
let fit: FitAddon | null = null;
|
let fit: FitAddon | null = null;
|
||||||
let ro: ResizeObserver | null = null;
|
let ro: ResizeObserver | null = null;
|
||||||
|
let pingInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
function wsUrl(hostId: number): string {
|
function wsUrl(hostId: number): string {
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
@@ -86,6 +87,12 @@ onMounted(async () => {
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
status.value = "Handshaking…";
|
status.value = "Handshaking…";
|
||||||
sendResize();
|
sendResize();
|
||||||
|
// Send ping every 60 seconds to keep connection alive
|
||||||
|
pingInterval = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
@@ -115,6 +122,10 @@ onMounted(async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
if (!connId.value) {
|
if (!connId.value) {
|
||||||
status.value = "Disconnected";
|
status.value = "Disconnected";
|
||||||
} else {
|
} else {
|
||||||
@@ -124,6 +135,10 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (pingInterval) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
pingInterval = null;
|
||||||
|
}
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
ro = null;
|
ro = null;
|
||||||
ws?.close();
|
ws?.close();
|
||||||
|
|||||||
Submodule
+1
Submodule jdbnet.co.uk added at 4c2697f274
Reference in New Issue
Block a user