mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62047dee5f | ||
|
|
db0dea08cf | ||
|
|
096db5c636 | ||
|
|
9b7f52b629 | ||
|
|
08825c256d | ||
|
|
f44d09eef7 | ||
|
|
36ab7e4872 | ||
|
|
c1c06272d7 | ||
|
|
00c0fe7cab | ||
|
|
09aa75b51b | ||
|
|
1441ffef99 | ||
|
|
0f671c6f4a | ||
|
|
7ba45969f6 | ||
|
|
8da7b25c81 |
@@ -28,7 +28,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Crowdin Sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: "Branch to sync translations into"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
crowdin:
|
||||||
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Resolve target branch
|
||||||
|
id: branch
|
||||||
|
run: |
|
||||||
|
BRANCH="${{ inputs.branch }}"
|
||||||
|
if [ -z "$BRANCH" ]; then
|
||||||
|
BRANCH="${{ github.event.repository.default_branch }}"
|
||||||
|
fi
|
||||||
|
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Checkout branch
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.branch.outputs.name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
|
- name: Upload sources to Crowdin
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
upload_sources: true
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: false
|
||||||
|
create_pull_request: false
|
||||||
|
push_translations: false
|
||||||
|
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
project_id: "858252"
|
||||||
|
env:
|
||||||
|
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
|
||||||
|
- name: Machine pre-translate untranslated strings
|
||||||
|
env:
|
||||||
|
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
run: node scripts/crowdin-pretranslate.cjs
|
||||||
|
|
||||||
|
- name: Download translations from Crowdin
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
with:
|
||||||
|
upload_sources: false
|
||||||
|
upload_translations: false
|
||||||
|
download_translations: true
|
||||||
|
create_pull_request: false
|
||||||
|
push_translations: false
|
||||||
|
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
project_id: "858252"
|
||||||
|
env:
|
||||||
|
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||||
|
|
||||||
|
- name: Commit translations
|
||||||
|
run: |
|
||||||
|
git config user.name "LukeGus"
|
||||||
|
git config user.email "bugattiguy527@gmail.com"
|
||||||
|
|
||||||
|
git add src/ui/locales/translated
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No translation changes to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "chore: sync Crowdin translations"
|
||||||
|
git push origin HEAD:"${{ steps.branch.outputs.name }}"
|
||||||
@@ -43,6 +43,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||||
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -166,7 +166,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -380,7 +380,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -966,7 +966,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ jobs:
|
|||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ jobs:
|
|||||||
path: termix
|
path: termix
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: "termix/.nvmrc"
|
node-version-file: "termix/.nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -513,7 +513,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ networks:
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
Termix sends a small anonymous usage ping once every 24 hours to help understand how many instances are running and which features are actually used. This only includes a randomly generated instance ID, a count of users and hosts, the app version, and whether certain features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never includes usernames, hostnames, IP addresses, credentials, or any other identifying or connection data.
|
||||||
|
|
||||||
|
This is opt-out and enabled by default. You can disable it at any time in Admin Settings under **General**.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
|
|
||||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
|
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
|
||||||
|
|||||||
+2
-1
@@ -58,7 +58,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production \
|
||||||
|
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
|
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
|
||||||
update-ca-certificates && \
|
update-ca-certificates && \
|
||||||
|
|||||||
@@ -226,6 +226,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/sync(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/termix-id(/.*)?$ {
|
location ~ ^/termix-id(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -467,6 +476,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/sync(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/termix-id(/.*)?$ {
|
location ~ ^/termix-id(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -456,6 +465,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
+81
-8
@@ -20,6 +20,7 @@ const net = require("net");
|
|||||||
const { URL } = require("url");
|
const { URL } = require("url");
|
||||||
const { fork, spawn } = require("child_process");
|
const { fork, spawn } = require("child_process");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
|
const remoteSync = require("./remote-sync.cjs");
|
||||||
|
|
||||||
// Portable mode: if a `.portable` marker exists next to the executable,
|
// Portable mode: if a `.portable` marker exists next to the executable,
|
||||||
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
||||||
@@ -852,6 +853,7 @@ function startBackendServer() {
|
|||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
ELECTRON_EMBEDDED: "true",
|
ELECTRON_EMBEDDED: "true",
|
||||||
PORT: "30001",
|
PORT: "30001",
|
||||||
|
VERSION: app.getVersion(),
|
||||||
},
|
},
|
||||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||||
});
|
});
|
||||||
@@ -1335,7 +1337,6 @@ ipcMain.handle("get-embedded-server-status", () => {
|
|||||||
return {
|
return {
|
||||||
running:
|
running:
|
||||||
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
||||||
embedded: !isDev,
|
|
||||||
dataDir: isDev ? null : getBackendDataDir(),
|
dataDir: isDev ? null : getBackendDataDir(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1442,6 +1443,70 @@ ipcMain.handle("save-server-config", (event, config) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
|
||||||
|
|
||||||
|
ipcMain.handle("get-desktop-settings", () => {
|
||||||
|
return remoteSync.getDesktopSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
||||||
|
return remoteSync.saveDesktopSettings(settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-remote-sync-config", () => {
|
||||||
|
return remoteSync.getRemoteSyncConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("save-remote-sync-config", (_event, config) => {
|
||||||
|
return remoteSync.saveRemoteSyncConfig(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("clear-remote-sync-config", async () => {
|
||||||
|
const result = remoteSync.clearRemoteSyncConfig();
|
||||||
|
remoteSync.clearRemoteSyncJwt();
|
||||||
|
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||||
|
connected: false,
|
||||||
|
syncing: false,
|
||||||
|
needsReauth: false,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||||
|
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||||
|
if (result.success) {
|
||||||
|
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||||
|
connected: true,
|
||||||
|
needsReauth: false,
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-remote-sync-jwt", () => {
|
||||||
|
return remoteSync.getRemoteSyncJwt();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("clear-remote-sync-jwt", () => {
|
||||||
|
return remoteSync.clearRemoteSyncJwt();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("get-remote-sync-status", () => {
|
||||||
|
return remoteSync.getRemoteSyncEngine()?.status || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("remote-sync-now", async () => {
|
||||||
|
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("notify-local-login", (_event, token) => {
|
||||||
|
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
function getC2STunnelConfigPath() {
|
function getC2STunnelConfigPath() {
|
||||||
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
||||||
}
|
}
|
||||||
@@ -1593,16 +1658,23 @@ function getC2SRelayUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getC2SRelayHeaders(relayUrl) {
|
async function getC2SRelayHeaders(relayUrl) {
|
||||||
if (!mainWindow?.webContents?.session) return {};
|
|
||||||
|
|
||||||
const cookieUrl = relayUrl
|
const cookieUrl = relayUrl
|
||||||
.replace(/^ws:/, "http:")
|
.replace(/^ws:/, "http:")
|
||||||
.replace(/^wss:/, "https:");
|
.replace(/^wss:/, "https:");
|
||||||
const cookies = await mainWindow.webContents.session.cookies.get({
|
|
||||||
url: cookieUrl,
|
let jwt;
|
||||||
name: "jwt",
|
if (mainWindow?.webContents?.session) {
|
||||||
});
|
const cookies = await mainWindow.webContents.session.cookies.get({
|
||||||
const jwt = cookies[0]?.value;
|
url: cookieUrl,
|
||||||
|
name: "jwt",
|
||||||
|
});
|
||||||
|
jwt = cookies[0]?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jwt) {
|
||||||
|
jwt = getRememberedElectronAuthCookie("jwt", cookieUrl)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!jwt) return {};
|
if (!jwt) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2967,6 +3039,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
createTray();
|
createTray();
|
||||||
createWindow();
|
createWindow();
|
||||||
|
remoteSync.initRemoteSync(() => mainWindow);
|
||||||
logToFile("=== Startup complete ===");
|
logToFile("=== Startup complete ===");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
startC2SAutoStartTunnels: () =>
|
startC2SAutoStartTunnels: () =>
|
||||||
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
||||||
|
|
||||||
|
onRemoteSyncStatusChanged: (callback) => {
|
||||||
|
const listener = (_event, status) => callback(status);
|
||||||
|
ipcRenderer.on("remote-sync-status-changed", listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener("remote-sync-status-changed", listener);
|
||||||
|
},
|
||||||
|
|
||||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||||
getSessionCookie: (name, targetUrl) =>
|
getSessionCookie: (name, targetUrl) =>
|
||||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
// Remote sync engine for the desktop app's optional connection to a
|
||||||
|
// self-hosted Termix server. Runs entirely in the Electron main process:
|
||||||
|
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
|
||||||
|
// the renderer's localStorage) and the local embedded backend's JWT
|
||||||
|
// (cached in memory only, handed over by the renderer at local-login
|
||||||
|
// time via notify-local-login).
|
||||||
|
// - On a timer, pulls + pushes each synced entity type between the
|
||||||
|
// embedded backend (always localhost:30001) and the configured remote
|
||||||
|
// server, reconciling by syncId with last-write-wins on updatedAt, and
|
||||||
|
// propagating tombstones (deletions) in both directions.
|
||||||
|
// - Pushes connection/sync status to the renderer via IPC so the Settings
|
||||||
|
// UI and a global banner can reflect it without polling.
|
||||||
|
|
||||||
|
const { app, safeStorage } = require("electron");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const SYNCED_ENTITY_TYPES = [
|
||||||
|
"hosts",
|
||||||
|
"sshCredentials",
|
||||||
|
"sshFolders",
|
||||||
|
"snippets",
|
||||||
|
"snippetFolders",
|
||||||
|
"vaultProfiles",
|
||||||
|
"dashboardServiceLinks",
|
||||||
|
"homepageItems",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYNC_INTERVAL_MS = 90 * 1000;
|
||||||
|
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
|
||||||
|
|
||||||
|
function dataPath(filename) {
|
||||||
|
return path.join(app.getPath("userData"), filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return fallback;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath, value) {
|
||||||
|
const userDataPath = app.getPath("userData");
|
||||||
|
if (!fs.existsSync(userDataPath)) {
|
||||||
|
fs.mkdirSync(userDataPath, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopSettingsPath() {
|
||||||
|
return dataPath("desktop-settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncConfigPath() {
|
||||||
|
return dataPath("remote-sync-config.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncCredentialPath() {
|
||||||
|
return dataPath("remote-sync-credential.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncStatePath() {
|
||||||
|
return dataPath("remote-sync-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopSettings() {
|
||||||
|
return readJson(getDesktopSettingsPath(), {
|
||||||
|
defaultConnectionOrigin: "local",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDesktopSettings(settings) {
|
||||||
|
writeJson(getDesktopSettingsPath(), settings);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncConfig() {
|
||||||
|
return readJson(getRemoteSyncConfigPath(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRemoteSyncConfig(config) {
|
||||||
|
writeJson(getRemoteSyncConfigPath(), config);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRemoteSyncConfig() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getRemoteSyncConfigPath());
|
||||||
|
} catch {
|
||||||
|
// already absent
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeStorageAvailable() {
|
||||||
|
try {
|
||||||
|
return safeStorage.isEncryptionAvailable();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRemoteSyncJwt(token) {
|
||||||
|
if (!getSafeStorageAvailable()) {
|
||||||
|
return { success: false, error: "Encryption unavailable on this system" };
|
||||||
|
}
|
||||||
|
writeJson(getRemoteSyncCredentialPath(), {
|
||||||
|
encrypted: true,
|
||||||
|
value: safeStorage.encryptString(token).toString("base64"),
|
||||||
|
obtainedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncJwt() {
|
||||||
|
const record = readJson(getRemoteSyncCredentialPath(), null);
|
||||||
|
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRemoteSyncJwt() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getRemoteSyncCredentialPath());
|
||||||
|
} catch {
|
||||||
|
// already absent
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpiry(token) {
|
||||||
|
try {
|
||||||
|
const payloadB64 = token.split(".")[1];
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(payloadB64, "base64").toString("utf8"),
|
||||||
|
);
|
||||||
|
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
|
||||||
|
const expiresAt = decodeJwtExpiry(token);
|
||||||
|
if (expiresAt === null) return false;
|
||||||
|
return Date.now() + marginMs >= expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteSyncEngine {
|
||||||
|
constructor(getMainWindow) {
|
||||||
|
this.getMainWindow = getMainWindow;
|
||||||
|
this.localJwt = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.syncing = false;
|
||||||
|
this.status = {
|
||||||
|
connected: false,
|
||||||
|
syncing: false,
|
||||||
|
lastSyncedAt: null,
|
||||||
|
lastError: null,
|
||||||
|
needsReauth: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalJwt(token) {
|
||||||
|
this.localJwt = token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitStatus() {
|
||||||
|
const win = this.getMainWindow?.();
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.webContents.send("remote-sync-status-changed", this.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(patch) {
|
||||||
|
this.status = { ...this.status, ...patch };
|
||||||
|
this.emitStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const config = getRemoteSyncConfig();
|
||||||
|
this.status.connected = !!config?.serverUrl;
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
|
||||||
|
if (config?.serverUrl) {
|
||||||
|
// Fire an initial sync shortly after startup rather than waiting a
|
||||||
|
// full interval, but don't block app boot on it.
|
||||||
|
setTimeout(() => this.syncNow(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncNow() {
|
||||||
|
if (this.syncing) return this.status;
|
||||||
|
const config = getRemoteSyncConfig();
|
||||||
|
if (!config?.serverUrl) {
|
||||||
|
this.updateStatus({ connected: false, syncing: false });
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteJwt = getRemoteSyncJwt();
|
||||||
|
if (!remoteJwt) {
|
||||||
|
this.updateStatus({
|
||||||
|
connected: true,
|
||||||
|
syncing: false,
|
||||||
|
needsReauth: true,
|
||||||
|
lastError: "Not signed in to remote server",
|
||||||
|
});
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
|
||||||
|
this.updateStatus({
|
||||||
|
connected: true,
|
||||||
|
syncing: false,
|
||||||
|
needsReauth: true,
|
||||||
|
lastError: "Remote session expired",
|
||||||
|
});
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
if (!this.localJwt) {
|
||||||
|
// Local login hasn't handed us a token yet (e.g. very early after
|
||||||
|
// boot) -- skip this tick rather than fail loudly.
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncing = true;
|
||||||
|
this.updateStatus({ connected: true, syncing: true, lastError: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
|
||||||
|
let sawAuthFailure = false;
|
||||||
|
|
||||||
|
for (const entityType of SYNCED_ENTITY_TYPES) {
|
||||||
|
const entityState = state.entities[entityType] || {
|
||||||
|
lastPulledAt: null,
|
||||||
|
lastPushedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.syncEntity({
|
||||||
|
entityType,
|
||||||
|
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
|
||||||
|
remoteJwt,
|
||||||
|
since: entityState.lastPulledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.authFailure) {
|
||||||
|
sawAuthFailure = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.entities[entityType] = {
|
||||||
|
lastPulledAt: result.syncedAt,
|
||||||
|
lastPushedAt: result.syncedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sawAuthFailure) {
|
||||||
|
this.updateStatus({
|
||||||
|
syncing: false,
|
||||||
|
needsReauth: true,
|
||||||
|
lastError: "Remote server rejected the session",
|
||||||
|
});
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(getRemoteSyncStatePath(), state);
|
||||||
|
writeJson(getRemoteSyncConfigPath(), {
|
||||||
|
...config,
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
lastSyncStatus: "ok",
|
||||||
|
lastSyncError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateStatus({
|
||||||
|
connected: true,
|
||||||
|
syncing: false,
|
||||||
|
needsReauth: false,
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
lastError: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
writeJson(getRemoteSyncConfigPath(), {
|
||||||
|
...config,
|
||||||
|
lastSyncStatus: "error",
|
||||||
|
lastSyncError: message,
|
||||||
|
});
|
||||||
|
this.updateStatus({ syncing: false, lastError: message });
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchJson(url, token, options = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const err = new Error(`Auth failed (${res.status})`);
|
||||||
|
err.authFailure = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Request failed (${res.status}): ${url}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullSide(baseUrl, token, entityType, since) {
|
||||||
|
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
||||||
|
const data = await this.fetchJson(url, token);
|
||||||
|
return data.rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullTombstones(baseUrl, token, entityType, since) {
|
||||||
|
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
|
||||||
|
const data = await this.fetchJson(url, token);
|
||||||
|
return data.tombstones || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushRow(baseUrl, token, entityType, row) {
|
||||||
|
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ row }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushTombstone(baseUrl, token, entityType, syncId) {
|
||||||
|
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ entityType, syncId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
|
||||||
|
const syncedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const [localRows, remoteRows, localTombstones, remoteTombstones] =
|
||||||
|
await Promise.all([
|
||||||
|
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
|
||||||
|
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
|
||||||
|
this.pullTombstones(
|
||||||
|
EMBEDDED_BASE_URL,
|
||||||
|
this.localJwt,
|
||||||
|
entityType,
|
||||||
|
since,
|
||||||
|
),
|
||||||
|
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tombstonedSyncIds = new Set([
|
||||||
|
...localTombstones.map((t) => t.syncId),
|
||||||
|
...remoteTombstones.map((t) => t.syncId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const localBySyncId = new Map(
|
||||||
|
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
|
||||||
|
);
|
||||||
|
const remoteBySyncId = new Map(
|
||||||
|
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
|
||||||
|
);
|
||||||
|
const allSyncIds = new Set([
|
||||||
|
...localBySyncId.keys(),
|
||||||
|
...remoteBySyncId.keys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const syncId of allSyncIds) {
|
||||||
|
if (tombstonedSyncIds.has(syncId)) continue;
|
||||||
|
|
||||||
|
const localRow = localBySyncId.get(syncId);
|
||||||
|
const remoteRow = remoteBySyncId.get(syncId);
|
||||||
|
|
||||||
|
if (localRow && !remoteRow) {
|
||||||
|
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
|
||||||
|
} else if (remoteRow && !localRow) {
|
||||||
|
await this.pushRow(
|
||||||
|
EMBEDDED_BASE_URL,
|
||||||
|
this.localJwt,
|
||||||
|
entityType,
|
||||||
|
remoteRow,
|
||||||
|
);
|
||||||
|
} else if (localRow && remoteRow) {
|
||||||
|
const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime();
|
||||||
|
const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime();
|
||||||
|
if (localUpdatedAt > remoteUpdatedAt) {
|
||||||
|
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
|
||||||
|
} else if (remoteUpdatedAt > localUpdatedAt) {
|
||||||
|
await this.pushRow(
|
||||||
|
EMBEDDED_BASE_URL,
|
||||||
|
this.localJwt,
|
||||||
|
entityType,
|
||||||
|
remoteRow,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tombstones to whichever side hasn't already deleted the row.
|
||||||
|
for (const tombstone of localTombstones) {
|
||||||
|
if (remoteBySyncId.has(tombstone.syncId)) {
|
||||||
|
await this.pushTombstone(
|
||||||
|
remoteBaseUrl,
|
||||||
|
remoteJwt,
|
||||||
|
entityType,
|
||||||
|
tombstone.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tombstone of remoteTombstones) {
|
||||||
|
if (localBySyncId.has(tombstone.syncId)) {
|
||||||
|
await this.pushTombstone(
|
||||||
|
EMBEDDED_BASE_URL,
|
||||||
|
this.localJwt,
|
||||||
|
entityType,
|
||||||
|
tombstone.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { syncedAt };
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.authFailure) {
|
||||||
|
return { syncedAt, authFailure: true };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = null;
|
||||||
|
|
||||||
|
function initRemoteSync(getMainWindow) {
|
||||||
|
engine = new RemoteSyncEngine(getMainWindow);
|
||||||
|
engine.start();
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemoteSyncEngine() {
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initRemoteSync,
|
||||||
|
getRemoteSyncEngine,
|
||||||
|
getDesktopSettings,
|
||||||
|
saveDesktopSettings,
|
||||||
|
getRemoteSyncConfig,
|
||||||
|
saveRemoteSyncConfig,
|
||||||
|
clearRemoteSyncConfig,
|
||||||
|
saveRemoteSyncJwt,
|
||||||
|
getRemoteSyncJwt,
|
||||||
|
clearRemoteSyncJwt,
|
||||||
|
isJwtExpiredOrExpiringSoon,
|
||||||
|
decodeJwtExpiry,
|
||||||
|
};
|
||||||
+4
-1
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
|
||||||
<meta name="theme-color" content="#09090b" />
|
<meta name="theme-color" content="#09090b" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|||||||
Generated
+557
-554
File diff suppressed because it is too large
Load Diff
+31
-31
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.5.1",
|
"version": "2.6.0",
|
||||||
"description": "Self-hosted SSH and remote desktop management.",
|
"description": "Self-hosted SSH and remote desktop management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"biome:check": "biome check biome.json package.json",
|
"biome:check": "biome check biome.json package.json",
|
||||||
"biome:fix": "biome check --write biome.json package.json",
|
"biome:fix": "biome check --write biome.json package.json",
|
||||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
|
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
@@ -65,22 +65,22 @@
|
|||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.16",
|
"nanoid": "^6.0.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.7.0",
|
"undici": "^8.7.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.21.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.2",
|
"@biomejs/biome": "2.5.4",
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.4",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/search": "^6.7.1",
|
"@codemirror/search": "^6.7.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.43.5",
|
"@codemirror/view": "^6.43.6",
|
||||||
"@commitlint/cli": "^21.0.2",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.2",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
@@ -92,23 +92,23 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/source-code-pro": "^5.2.7",
|
"@fontsource/source-code-pro": "^5.2.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.15",
|
"@radix-ui/react-accordion": "^1.2.17",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.18",
|
"@radix-ui/react-alert-dialog": "^1.1.20",
|
||||||
"@radix-ui/react-checkbox": "^1.3.6",
|
"@radix-ui/react-checkbox": "^1.3.8",
|
||||||
"@radix-ui/react-dialog": "^1.1.18",
|
"@radix-ui/react-dialog": "^1.1.20",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
"@radix-ui/react-dropdown-menu": "^2.1.21",
|
||||||
"@radix-ui/react-label": "^2.1.11",
|
"@radix-ui/react-label": "^2.1.12",
|
||||||
"@radix-ui/react-popover": "^1.1.18",
|
"@radix-ui/react-popover": "^1.1.20",
|
||||||
"@radix-ui/react-progress": "^1.1.11",
|
"@radix-ui/react-progress": "^1.1.13",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
"@radix-ui/react-scroll-area": "^1.2.15",
|
||||||
"@radix-ui/react-select": "^2.3.2",
|
"@radix-ui/react-select": "^2.3.4",
|
||||||
"@radix-ui/react-separator": "^1.1.11",
|
"@radix-ui/react-separator": "^1.1.12",
|
||||||
"@radix-ui/react-slider": "^1.4.2",
|
"@radix-ui/react-slider": "^1.4.4",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.2",
|
"@radix-ui/react-switch": "^1.3.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.16",
|
"@radix-ui/react-tabs": "^1.1.18",
|
||||||
"@radix-ui/react-tooltip": "^1.2.11",
|
"@radix-ui/react-tooltip": "^1.2.13",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -128,12 +128,12 @@
|
|||||||
"@types/speakeasy": "^2.0.10",
|
"@types/speakeasy": "^2.0.10",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||||
"@uiw/react-codemirror": "^4.25.9",
|
"@uiw/react-codemirror": "^4.25.11",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"@vitest/ui": "^4.1.9",
|
"@vitest/ui": "^4.1.10",
|
||||||
"@xterm/addon-clipboard": "^0.2.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-unicode11": "^0.9.0",
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
@@ -153,19 +153,19 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^17.0.8",
|
"lint-staged": "^17.0.8",
|
||||||
"lucide-react": "^1.20.0",
|
"lucide-react": "^1.20.0",
|
||||||
"prettier": "3.8.4",
|
"prettier": "3.8.4",
|
||||||
"radix-ui": "^1.6.1",
|
"radix-ui": "^1.6.3",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-h5-audio-player": "^3.10.2",
|
"react-h5-audio-player": "^3.10.2",
|
||||||
"react-hook-form": "^7.79.0",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.10",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.4.1",
|
"react-pdf": "^10.4.1",
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
"typescript-eslint": "^8.61.1",
|
"typescript-eslint": "^8.61.1",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx}": [
|
"*.{ts,tsx}": [
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const packageRoot = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"guacamole-common-js",
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundlePaths = [
|
||||||
|
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
|
||||||
|
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const oldFlushBlock =
|
||||||
|
" if (window.requestAnimationFrame && document.hasFocus())\n" +
|
||||||
|
" asyncFlush();\n" +
|
||||||
|
" else\n" +
|
||||||
|
" syncFlush();";
|
||||||
|
|
||||||
|
const newFlushBlock =
|
||||||
|
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
|
||||||
|
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
|
||||||
|
" // synchronously so Guacamole connections do not stall while waiting for\n" +
|
||||||
|
" // a frame callback that may never run.\n" +
|
||||||
|
" syncFlush();";
|
||||||
|
|
||||||
|
let patched = false;
|
||||||
|
let foundBundle = false;
|
||||||
|
|
||||||
|
for (const bundlePath of bundlePaths) {
|
||||||
|
if (!fs.existsSync(bundlePath)) {
|
||||||
|
console.log(
|
||||||
|
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foundBundle = true;
|
||||||
|
let content = fs.readFileSync(bundlePath, "utf8");
|
||||||
|
if (content.includes(newFlushBlock)) continue;
|
||||||
|
|
||||||
|
if (!content.includes(oldFlushBlock)) {
|
||||||
|
console.log(
|
||||||
|
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
content = content.replace(oldFlushBlock, newFlushBlock);
|
||||||
|
fs.writeFileSync(bundlePath, content);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundBundle) {
|
||||||
|
console.log("[patch-guacamole-common-js] File not found, skipping");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!patched) {
|
||||||
|
console.log("[patch-guacamole-common-js] Already patched");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
|
||||||
|
);
|
||||||
@@ -17,14 +17,27 @@ const cryptPath = path.join(
|
|||||||
"lib",
|
"lib",
|
||||||
"Crypt.js",
|
"Crypt.js",
|
||||||
);
|
);
|
||||||
|
const clientConnectionPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"guacamole-lite",
|
||||||
|
"lib",
|
||||||
|
"ClientConnection.js",
|
||||||
|
);
|
||||||
|
|
||||||
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
if (
|
||||||
|
!fs.existsSync(guacdClientPath) ||
|
||||||
|
!fs.existsSync(cryptPath) ||
|
||||||
|
!fs.existsSync(clientConnectionPath)
|
||||||
|
) {
|
||||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||||
|
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||||
|
|
||||||
// Patch 1: protocol version negotiation.
|
// Patch 1: protocol version negotiation.
|
||||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||||
@@ -56,20 +69,26 @@ const newVersionBlock =
|
|||||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||||
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
||||||
|
|
||||||
// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0.
|
// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
|
||||||
// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional
|
// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
|
||||||
// human-readable identifier for the joining user). guacd 1.6.0 began requiring
|
// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
|
||||||
// it during the VNC handshake even when negotiating older protocol versions,
|
// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
|
||||||
// causing connections to silently drop right after "User joined". See
|
// harmless (guacd ignores unknown handshake instructions for older versions). See
|
||||||
// Termix-SSH/Support#567 and #734.
|
// Termix-SSH/Support#567 and #734.
|
||||||
const oldConnect =
|
const oldConnect =
|
||||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||||
const newConnect =
|
const oldNameConnect =
|
||||||
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
|
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
|
||||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||||
" }\n" +
|
" }\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||||
|
const newConnect =
|
||||||
|
" if (protocolVersion !== '1_0_0') {\n" +
|
||||||
|
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||||
|
|
||||||
// Patch 4: answer guacd's dynamic argument requests locally.
|
// Patch 4: answer guacd's dynamic argument requests locally.
|
||||||
// macOS Screen Sharing can request VNC username/password through the
|
// macOS Screen Sharing can request VNC username/password through the
|
||||||
@@ -156,13 +175,16 @@ if (!guacdClientContent.includes(newTimezone)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!guacdClientContent.includes(newConnect)) {
|
if (!guacdClientContent.includes(newConnect)) {
|
||||||
if (!guacdClientContent.includes(oldConnect)) {
|
if (guacdClientContent.includes(oldNameConnect)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect);
|
||||||
|
} else if (guacdClientContent.includes(oldConnect)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
||||||
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Connect target not found, skipping name patch",
|
"[patch-guacamole-lite] Connect target not found, skipping name patch",
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
|
||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
|
|||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch 7: drop client-to-guacd input instructions from read-only session-share
|
||||||
|
// joins. guacd has no native read-only enforcement in the versions this project
|
||||||
|
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
|
||||||
|
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
|
||||||
|
// clipboard streams) than a new input vector, so failing open is the safer
|
||||||
|
// default for a client we already control.
|
||||||
|
const oldSendMessageToGuacd =
|
||||||
|
" sendMessageToGuacd(message) {\n" +
|
||||||
|
" this.lastActivity = Date.now();\n" +
|
||||||
|
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.guacdClient) {\n" +
|
||||||
|
" this.guacdClient.send(message, true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
" }";
|
||||||
|
const newSendMessageToGuacd =
|
||||||
|
" sendMessageToGuacd(message) {\n" +
|
||||||
|
" this.lastActivity = Date.now();\n" +
|
||||||
|
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
|
||||||
|
" return;\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.guacdClient) {\n" +
|
||||||
|
" this.guacdClient.send(message, true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" isReadOnlyJoin() {\n" +
|
||||||
|
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
|
||||||
|
" return !!(connection && connection.join && connection.readOnly === true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
|
||||||
|
" // the leading opcode from a raw '<len>.<opcode>,...;' instruction without the\n" +
|
||||||
|
" // overhead of a full stateful parse.\n" +
|
||||||
|
" isInputInstruction(message) {\n" +
|
||||||
|
" const dot = message.indexOf('.');\n" +
|
||||||
|
" if (dot === -1) return false;\n" +
|
||||||
|
" const len = parseInt(message.substring(0, dot), 10);\n" +
|
||||||
|
" if (isNaN(len)) return false;\n" +
|
||||||
|
" const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
|
||||||
|
" return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
|
||||||
|
" }";
|
||||||
|
|
||||||
|
if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
|
||||||
|
if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
|
||||||
|
console.log(
|
||||||
|
"[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch",
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
clientConnectionContent = clientConnectionContent.replace(
|
||||||
|
oldSendMessageToGuacd,
|
||||||
|
newSendMessageToGuacd,
|
||||||
|
);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch 8: mergeConnectionOptions only preserves `join` across the settings
|
||||||
|
// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
|
||||||
|
const oldPreserveJoin =
|
||||||
|
" // For join connections, preserve the join property\n" +
|
||||||
|
" if (this.connectionSettings.connection.join) {\n" +
|
||||||
|
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||||
|
" }";
|
||||||
|
const newPreserveJoin =
|
||||||
|
" // For join connections, preserve the join property\n" +
|
||||||
|
" if (this.connectionSettings.connection.join) {\n" +
|
||||||
|
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||||
|
" compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
|
||||||
|
" }";
|
||||||
|
|
||||||
|
if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
||||||
|
if (!clientConnectionContent.includes(oldPreserveJoin)) {
|
||||||
|
console.log(
|
||||||
|
"[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch",
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
clientConnectionContent = clientConnectionContent.replace(
|
||||||
|
oldPreserveJoin,
|
||||||
|
newPreserveJoin,
|
||||||
|
);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!patched) {
|
if (!patched) {
|
||||||
console.log("[patch-guacamole-lite] Already patched");
|
console.log("[patch-guacamole-lite] Already patched");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -266,6 +376,7 @@ if (!patched) {
|
|||||||
|
|
||||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||||
fs.writeFileSync(cryptPath, cryptContent);
|
fs.writeFileSync(cryptPath, cryptContent);
|
||||||
|
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
|
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ describe("patch-guacamole-lite", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => {
|
||||||
|
const client = createPatchedClient({
|
||||||
|
hostname: "192.0.2.10",
|
||||||
|
port: 5900,
|
||||||
|
password: "secret",
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
dpi: 96,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]);
|
||||||
|
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||||
|
"name",
|
||||||
|
"guacamole-lite",
|
||||||
|
]);
|
||||||
|
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||||
|
"connect",
|
||||||
|
"VERSION_1_1_0",
|
||||||
|
"192.0.2.10",
|
||||||
|
5900,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("answers required credentials through argument value streams", () => {
|
it("answers required credentials through argument value streams", () => {
|
||||||
const client = createPatchedClient({
|
const client = createPatchedClient({
|
||||||
username: "",
|
username: "",
|
||||||
|
|||||||
+28
-15
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
|
|||||||
# define __builtin_frame_address(level) _AddressOfReturnAddress()
|
# define __builtin_frame_address(level) _AddressOfReturnAddress()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag
|
||||||
|
// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24)
|
||||||
|
// still uses the old 2-arg signatures, so this must be conditional rather
|
||||||
|
// than assumed - a build can target either header set.
|
||||||
|
#include <v8-version.h>
|
||||||
|
#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15
|
||||||
|
# define NAN_EXTERNAL_TAG_ARG , static_cast<v8::ExternalPointerTypeTag>(0)
|
||||||
|
# define NAN_EXTERNAL_TAG_PARAM static_cast<v8::ExternalPointerTypeTag>(0)
|
||||||
|
#else
|
||||||
|
# define NAN_EXTERNAL_TAG_ARG
|
||||||
|
# define NAN_EXTERNAL_TAG_PARAM
|
||||||
|
#endif
|
||||||
|
|
||||||
#define NODE_0_10_MODULE_VERSION 11`,
|
#define NODE_0_10_MODULE_VERSION 11`,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form.
|
// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
|
||||||
// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument.
|
// passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above)
|
||||||
|
// that expands to the ExternalPointerTypeTag argument only when the target
|
||||||
|
// V8 headers actually declare it (V8 15+ / Electron 43+).
|
||||||
const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
|
const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
|
||||||
let implPatched = false;
|
let implPatched = false;
|
||||||
if (fs.existsSync(implPath)) {
|
if (fs.existsSync(implPath)) {
|
||||||
let src = fs.readFileSync(implPath, "utf8");
|
let src = fs.readFileSync(implPath, "utf8");
|
||||||
const before = src;
|
const before = src;
|
||||||
|
|
||||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
|
||||||
if (!src.includes(TAG)) {
|
|
||||||
src = src.replace(
|
src = src.replace(
|
||||||
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g,
|
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||||
`v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`,
|
`v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
|
||||||
);
|
);
|
||||||
src = src.replace(
|
src = src.replace(
|
||||||
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)\)/g,
|
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||||
`v8::External::New(isolate, reinterpret_cast<void *>(callback), ${TAG})`,
|
`v8::External::New(isolate, reinterpret_cast<void *>(callback) NAN_EXTERNAL_TAG_ARG)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External.
|
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
|
||||||
// The new API requires an ExternalPointerTypeTag argument.
|
// on v8::External, same conditional-tag reasoning as above.
|
||||||
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
|
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
|
||||||
let callbacksPatched = false;
|
let callbacksPatched = false;
|
||||||
if (fs.existsSync(callbacksPath)) {
|
if (fs.existsSync(callbacksPath)) {
|
||||||
let src = fs.readFileSync(callbacksPath, "utf8");
|
let src = fs.readFileSync(callbacksPath, "utf8");
|
||||||
const before = src;
|
const before = src;
|
||||||
|
|
||||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
|
||||||
if (!src.includes(TAG)) {
|
// Pattern: .As<v8::External>()->Value()) or ->Value(<old hardcoded tag>))
|
||||||
// Pattern: .As<v8::External>()->Value()) — always followed by ))
|
|
||||||
src = src.replace(
|
src = src.replace(
|
||||||
/\.As<v8::External>\(\)->Value\(\)\)/g,
|
/\.As<v8::External>\(\)->Value\((?:static_cast<v8::ExternalPointerTypeTag>\(0\))?\)\)/g,
|
||||||
`.As<v8::External>()->Value(${TAG}))`,
|
`.As<v8::External>()->Value(NAN_EXTERNAL_TAG_PARAM))`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ const xtermDir = path.join(
|
|||||||
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||||
// composition on the previous word and replace it with a shorter value (for
|
// composition on the previous word and replace it with a shorter value (for
|
||||||
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||||
|
//
|
||||||
|
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
|
||||||
|
// for ordinary typing (it reports keyCode 229 for all software-keyboard
|
||||||
|
// input, not just IME composition). That handler diffs the textarea value
|
||||||
|
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
|
||||||
|
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
|
||||||
|
// several overlapping callbacks each capture a stale oldValue, so the
|
||||||
|
// literal-substring search fails to match and the diff silently comes back
|
||||||
|
// empty - characters are dropped instead of sent. Swap in the same
|
||||||
|
// common-prefix diff used for composition-end above so a stale oldValue
|
||||||
|
// still yields the correct delta.
|
||||||
const patches = [
|
const patches = [
|
||||||
{
|
{
|
||||||
file: "xterm.mjs",
|
file: "xterm.mjs",
|
||||||
@@ -34,6 +45,10 @@ const patches = [
|
|||||||
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||||
|
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,6 +70,10 @@ const patches = [
|
|||||||
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||||
|
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -66,18 +85,24 @@ for (const { file, replacements } of patches) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let source = fs.readFileSync(filePath, "utf8");
|
let source = fs.readFileSync(filePath, "utf8");
|
||||||
if (source.includes("_preCompositionValue")) {
|
let changed = false;
|
||||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [original, patched] of replacements) {
|
for (const [original, patched] of replacements) {
|
||||||
|
if (source.includes(patched)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!source.includes(original)) {
|
if (!source.includes(original)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
source = source.replace(original, patched);
|
source = source.replace(original, patched);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(filePath, source);
|
fs.writeFileSync(filePath, source);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
|||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||||
|
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
@@ -22,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
|||||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||||
import vaultRoutes from "./routes/vault.js";
|
import vaultRoutes from "./routes/vault.js";
|
||||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||||
|
import syncRoutes from "./routes/sync.js";
|
||||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -1737,6 +1739,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
|||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
app.use("/session_logs", sessionLogRoutes);
|
app.use("/session_logs", sessionLogRoutes);
|
||||||
app.use("/guacamole", guacamoleRoutes);
|
app.use("/guacamole", guacamoleRoutes);
|
||||||
|
app.use("/session-sharing", sessionSharingRoutes);
|
||||||
app.use("/network-topology", networkTopologyRoutes);
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
@@ -1747,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
|
|||||||
registerTailscaleRoutes(app, authenticateJWT);
|
registerTailscaleRoutes(app, authenticateJWT);
|
||||||
app.use("/vault", vaultRoutes);
|
app.use("/vault", vaultRoutes);
|
||||||
app.use("/", alertRulesRoutes);
|
app.use("/", alertRulesRoutes);
|
||||||
|
app.use("/sync", syncRoutes);
|
||||||
|
|
||||||
const frontendDistPaths = [
|
const frontendDistPaths = [
|
||||||
path.join(__dirname, "../../../dist"),
|
path.join(__dirname, "../../../dist"),
|
||||||
|
|||||||
@@ -390,9 +390,11 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
|
credential_id INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||||
@@ -493,6 +495,38 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT,
|
||||||
|
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -692,12 +726,16 @@ const addColumnIfNotExists = (
|
|||||||
sqlite.exec(`ALTER TABLE ${table}
|
sqlite.exec(`ALTER TABLE ${table}
|
||||||
ADD COLUMN "${column}" ${definition};`);
|
ADD COLUMN "${column}" ${definition};`);
|
||||||
} catch (alterError) {
|
} catch (alterError) {
|
||||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
const message =
|
||||||
operation: "schema_migration",
|
alterError instanceof Error ? alterError.message : String(alterError);
|
||||||
table,
|
databaseLogger.warn(
|
||||||
column,
|
`Failed to add column ${column} to ${table}: ${message}`,
|
||||||
error: alterError,
|
{
|
||||||
});
|
operation: "schema_migration",
|
||||||
|
table,
|
||||||
|
column,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -736,6 +774,7 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||||
@@ -1378,6 +1417,19 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
|
||||||
|
} catch (alterError) {
|
||||||
|
databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: alterError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1440,6 +1492,8 @@ const migrateSchema = () => {
|
|||||||
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
||||||
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
||||||
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
||||||
|
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
|
||||||
|
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const migration of sshDataMigrations) {
|
for (const migration of sshDataMigrations) {
|
||||||
@@ -1985,6 +2039,74 @@ const migrateSchema = () => {
|
|||||||
|
|
||||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||||
|
cid: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
notnull: number;
|
||||||
|
dflt_value: string | null;
|
||||||
|
pk: number;
|
||||||
|
}>;
|
||||||
|
const legacyNotNullColumns = new Set([
|
||||||
|
"client_id",
|
||||||
|
"client_secret",
|
||||||
|
"issuer_url",
|
||||||
|
"authorization_url",
|
||||||
|
"token_url",
|
||||||
|
"identifier_path",
|
||||||
|
"name_path",
|
||||||
|
"scopes",
|
||||||
|
]);
|
||||||
|
const hasStaleNotNull = usersTableInfo.some(
|
||||||
|
(col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasStaleNotNull) {
|
||||||
|
const tempTableName = "users_temp_migration";
|
||||||
|
const columnDefs = usersTableInfo
|
||||||
|
.map((col) => {
|
||||||
|
const parts = [`"${col.name}"`, col.type || "TEXT"];
|
||||||
|
if (col.pk === 1) parts.push("PRIMARY KEY");
|
||||||
|
if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
|
||||||
|
parts.push("NOT NULL");
|
||||||
|
}
|
||||||
|
if (col.dflt_value !== null) {
|
||||||
|
parts.push(`DEFAULT ${col.dflt_value}`);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
})
|
||||||
|
.join(",\n ");
|
||||||
|
const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
|
||||||
|
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE ${tempTableName} (
|
||||||
|
${columnDefs}
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
|
||||||
|
|
||||||
|
DROP TABLE users;
|
||||||
|
|
||||||
|
ALTER TABLE ${tempTableName} RENAME TO users;
|
||||||
|
`);
|
||||||
|
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
||||||
|
|
||||||
|
databaseLogger.info(
|
||||||
|
"Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
|
||||||
|
{
|
||||||
|
operation: "schema_migration_users_oidc_nullable",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (migrationError) {
|
||||||
|
databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: migrationError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||||
try {
|
try {
|
||||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||||
@@ -2206,6 +2328,174 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)",
|
||||||
|
);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)",
|
||||||
|
);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)",
|
||||||
|
);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create session_shares table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT,
|
||||||
|
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)",
|
||||||
|
);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create session_share_participants table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sync begin ---
|
||||||
|
// Stable per-row identity used to match rows across two independently-
|
||||||
|
// seeded databases (the embedded desktop backend and a connected remote
|
||||||
|
// server) during sync. Local autoincrement ids collide across instances,
|
||||||
|
// so a randomly-generated id is the join key instead. SQLite refuses a
|
||||||
|
// non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
|
||||||
|
// tables with existing constraints ("Cannot add a column with
|
||||||
|
// non-constant default"), so the column is added as plain nullable TEXT;
|
||||||
|
// repositories set syncId explicitly on insert going forward, and
|
||||||
|
// existing rows are backfilled by the UPDATE loop below.
|
||||||
|
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("snippets", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
|
||||||
|
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
|
||||||
|
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
|
||||||
|
// "non-constant default" reason -- add nullable, then backfill from
|
||||||
|
// created_at below and rely on the repository layer to keep it current.
|
||||||
|
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
|
||||||
|
try {
|
||||||
|
sqlite.exec(
|
||||||
|
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
databaseLogger.warn(
|
||||||
|
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
|
||||||
|
{ operation: "schema_migration", table: "dashboard_service_links" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
|
||||||
|
|
||||||
|
const syncIdTables = [
|
||||||
|
"ssh_data",
|
||||||
|
"ssh_credentials",
|
||||||
|
"ssh_folders",
|
||||||
|
"snippets",
|
||||||
|
"snippet_folders",
|
||||||
|
"vault_profiles",
|
||||||
|
"dashboard_service_links",
|
||||||
|
"homepage_items",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of syncIdTables) {
|
||||||
|
try {
|
||||||
|
const result = sqlite
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (result.changes > 0) {
|
||||||
|
databaseLogger.info(
|
||||||
|
`Backfilled sync_id for ${result.changes} row(s) in ${table}`,
|
||||||
|
{ operation: "sync_id_backfill", table },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sqlite.exec(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
databaseLogger.warn(
|
||||||
|
`Failed to backfill sync_id for ${table}: ${message}`,
|
||||||
|
{
|
||||||
|
operation: "sync_id_backfill",
|
||||||
|
table,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
sync_id TEXT NOT NULL,
|
||||||
|
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
|
||||||
|
);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create sync_tombstones table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- sync end ---
|
||||||
|
|
||||||
databaseLogger.success("Schema migration completed", {
|
databaseLogger.success("Schema migration completed", {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
@@ -237,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
socks5Password: text("socks5_password"),
|
socks5Password: text("socks5_password"),
|
||||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||||
|
|
||||||
|
// null = use the desktop app's global default; "local" | "remote" pins
|
||||||
|
// this specific host's SSH/Docker-console/Serial connections to originate
|
||||||
|
// from the embedded local backend or a connected remote sync server.
|
||||||
|
// Ignored for rdp/vnc/telnet, which always require the remote server.
|
||||||
|
connectionOrigin: text("connection_origin"),
|
||||||
|
|
||||||
macAddress: text("mac_address"),
|
macAddress: text("mac_address"),
|
||||||
wolBroadcastAddress: text("wol_broadcast_address"),
|
wolBroadcastAddress: text("wol_broadcast_address"),
|
||||||
portKnockSequence: text("port_knock_sequence"),
|
portKnockSequence: text("port_knock_sequence"),
|
||||||
@@ -248,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
hostKeyLastVerified: text("host_key_last_verified"),
|
hostKeyLastVerified: text("host_key_last_verified"),
|
||||||
hostKeyChangedCount: integer("host_key_changed_count").default(0),
|
hostKeyChangedCount: integer("host_key_changed_count").default(0),
|
||||||
|
|
||||||
|
// Stable identity used to match this row across two independently-seeded
|
||||||
|
// databases (the embedded backend and a connected remote server) during
|
||||||
|
// sync -- local autoincrement ids collide across instances.
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -354,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
|||||||
|
|
||||||
usageCount: integer("usage_count").notNull().default(0),
|
usageCount: integer("usage_count").notNull().default(0),
|
||||||
lastUsed: text("last_used"),
|
lastUsed: text("last_used"),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -388,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
folder: text("folder"),
|
folder: text("folder"),
|
||||||
order: integer("order").notNull().default(0),
|
order: integer("order").notNull().default(0),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -405,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
icon: text("icon"),
|
icon: text("icon"),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -462,6 +479,10 @@ export const sshFolders = sqliteTable("ssh_folders", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
icon: text("icon"),
|
icon: text("icon"),
|
||||||
|
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -673,6 +694,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
terminationReason: text("termination_reason"),
|
terminationReason: text("termination_reason"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sessionShares = sqliteTable("session_shares", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
|
||||||
|
hostId: integer("host_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
|
||||||
|
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||||
|
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||||
|
// row (process-local, in-memory) so this intentionally has no FK.
|
||||||
|
sessionId: text("session_id").notNull(),
|
||||||
|
tabInstanceId: text("tab_instance_id"),
|
||||||
|
|
||||||
|
shareType: text("share_type").notNull(), // "link" | "user"
|
||||||
|
targetUserId: text("target_user_id").references(() => users.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
linkToken: text("link_token").unique(),
|
||||||
|
|
||||||
|
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
expiresAt: text("expires_at").notNull(),
|
||||||
|
revokedAt: text("revoked_at"),
|
||||||
|
|
||||||
|
lastJoinedAt: text("last_joined_at"),
|
||||||
|
joinCount: integer("join_count").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionShareParticipants = sqliteTable(
|
||||||
|
"session_share_participants",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
shareId: text("share_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
userId: text("user_id").references(() => users.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
guestLabel: text("guest_label"),
|
||||||
|
|
||||||
|
joinedAt: text("joined_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
leftAt: text("left_at"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
@@ -724,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
|
|||||||
keyType: text("key_type"),
|
keyType: text("key_type"),
|
||||||
// When true the profile is visible/usable by all users on the server
|
// When true the profile is visible/usable by all users on the server
|
||||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -813,6 +891,7 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
|
customThemes: text("custom_themes"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -879,9 +958,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
|||||||
label: text("label").notNull(),
|
label: text("label").notNull(),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
order: integer("order").notNull().default(0),
|
order: integer("order").notNull().default(0),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- termix-id begin ---
|
// --- termix-id begin ---
|
||||||
@@ -1067,6 +1150,7 @@ export const homepageItems = sqliteTable("homepage_items", {
|
|||||||
title: text("title"),
|
title: text("title"),
|
||||||
config: text("config").notNull().default("{}"),
|
config: text("config").notNull().default("{}"),
|
||||||
folderId: integer("folder_id"),
|
folderId: integer("folder_id"),
|
||||||
|
syncId: text("sync_id").unique(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -1088,3 +1172,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
|
// --- sync begin ---
|
||||||
|
// Records a delete for a synced entity type so the other side of a sync
|
||||||
|
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||||
|
// the deletion instead of re-creating the row on its next pull.
|
||||||
|
export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
syncId: text("sync_id").notNull(),
|
||||||
|
deletedAt: text("deleted_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
// --- sync end ---
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
@@ -18,7 +19,7 @@ export class CredentialRepository {
|
|||||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.insert(sshCredentials)
|
.insert(sshCredentials)
|
||||||
.values(credential)
|
.values({ syncId: randomUUID(), ...credential })
|
||||||
.returning();
|
.returning();
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -30,7 +31,11 @@ export class CredentialRepository {
|
|||||||
): Promise<CredentialRecord> {
|
): Promise<CredentialRecord> {
|
||||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||||
const tempId = credential.id ?? Date.now();
|
const tempId = credential.id ?? Date.now();
|
||||||
const dataWithTempId = { ...credential, id: tempId };
|
const dataWithTempId = {
|
||||||
|
syncId: randomUUID(),
|
||||||
|
...credential,
|
||||||
|
id: tempId,
|
||||||
|
};
|
||||||
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
||||||
dataWithTempId,
|
dataWithTempId,
|
||||||
userId,
|
userId,
|
||||||
@@ -203,7 +208,10 @@ export class CredentialRepository {
|
|||||||
return this.decryptOne(rows[0] ?? null, userId);
|
return this.decryptOne(rows[0] ?? null, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
|
async deleteForUser(
|
||||||
|
userId: string,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(sshCredentials)
|
.delete(sshCredentials)
|
||||||
.where(
|
.where(
|
||||||
@@ -212,10 +220,10 @@ export class CredentialRepository {
|
|||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning({ id: sshCredentials.id });
|
.returning({ syncId: sshCredentials.syncId });
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length > 0;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { dashboardServiceLinks } from "../db/schema.js";
|
import { dashboardServiceLinks } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
|
|||||||
const [created] = await this.context.drizzle
|
const [created] = await this.context.drizzle
|
||||||
.insert(dashboardServiceLinks)
|
.insert(dashboardServiceLinks)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
order: nextOrder,
|
order: nextOrder,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
|
|||||||
): Promise<DashboardServiceLinkRecord | null> {
|
): Promise<DashboardServiceLinkRecord | null> {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await this.context.drizzle
|
||||||
.update(dashboardServiceLinks)
|
.update(dashboardServiceLinks)
|
||||||
.set(updates)
|
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(dashboardServiceLinks.id, id),
|
eq(dashboardServiceLinks.id, id),
|
||||||
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
|
|||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
async deleteForUser(
|
||||||
|
userId: string,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(dashboardServiceLinks)
|
.delete(dashboardServiceLinks)
|
||||||
.where(
|
.where(
|
||||||
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
|
|||||||
eq(dashboardServiceLinks.userId, userId),
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.returning({ id: dashboardServiceLinks.id });
|
.returning({ syncId: dashboardServiceLinks.syncId });
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
return rows[0];
|
||||||
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
|
|||||||
import { RoleRepository } from "./role-repository.js";
|
import { RoleRepository } from "./role-repository.js";
|
||||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
|
import { SessionShareRepository } from "./session-share-repository.js";
|
||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SnippetRepository } from "./snippet-repository.js";
|
import { SnippetRepository } from "./snippet-repository.js";
|
||||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
|
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||||
@@ -125,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||||
|
return new SyncTombstoneRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
|
||||||
return new DismissedAlertRepository(
|
return new DismissedAlertRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
@@ -253,6 +262,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||||
|
return new SessionShareRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||||
return new SettingsRepository(
|
return new SettingsRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { homepageItems } from "../db/schema.js";
|
import { homepageItems } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
|
|||||||
const [created] = await this.context.drizzle
|
const [created] = await this.context.drizzle
|
||||||
.insert(homepageItems)
|
.insert(homepageItems)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
typeId: input.typeId,
|
typeId: input.typeId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
|
|||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
async deleteForUser(
|
||||||
|
userId: string,
|
||||||
|
id: number,
|
||||||
|
): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(homepageItems)
|
.delete(homepageItems)
|
||||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||||
.returning({ id: homepageItems.id });
|
.returning({ syncId: homepageItems.syncId });
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
return rows[0];
|
||||||
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, eq, like, or, sql } from "drizzle-orm";
|
import { and, eq, like, or, sql } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
@@ -72,13 +73,20 @@ export class HostFolderRepository {
|
|||||||
name: string,
|
name: string,
|
||||||
color: string | null | undefined,
|
color: string | null | undefined,
|
||||||
icon: string | null | undefined,
|
icon: string | null | undefined,
|
||||||
|
credentialId?: number | null,
|
||||||
now = new Date().toISOString(),
|
now = new Date().toISOString(),
|
||||||
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
): Promise<{ folder: HostFolderRecord; created: boolean }> {
|
||||||
const existing = await this.findFolder(userId, name);
|
const existing = await this.findFolder(userId, name);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await this.context.drizzle
|
const [updated] = await this.context.drizzle
|
||||||
.update(sshFolders)
|
.update(sshFolders)
|
||||||
.set({ color, icon, updatedAt: now })
|
.set({
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
credentialId:
|
||||||
|
credentialId === undefined ? existing.credentialId : credentialId,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -89,10 +97,12 @@ export class HostFolderRepository {
|
|||||||
const [created] = await this.context.drizzle
|
const [created] = await this.context.drizzle
|
||||||
.insert(sshFolders)
|
.insert(sshFolders)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
credentialId: credentialId ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
@@ -118,7 +128,7 @@ export class HostFolderRepository {
|
|||||||
async deleteHostsAndFolderRecords(
|
async deleteHostsAndFolderRecords(
|
||||||
userId: string,
|
userId: string,
|
||||||
folderName: string,
|
folderName: string,
|
||||||
): Promise<void> {
|
): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
|
||||||
const folderMatch = (col: SQLiteColumn) =>
|
const folderMatch = (col: SQLiteColumn) =>
|
||||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||||
|
|
||||||
@@ -129,11 +139,21 @@ export class HostFolderRepository {
|
|||||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.context.drizzle
|
const deletedFolders = await this.context.drizzle
|
||||||
.delete(sshFolders)
|
.delete(sshFolders)
|
||||||
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
|
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
|
||||||
|
.returning({ syncId: sshFolders.syncId });
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostSyncIds: hostsToDelete
|
||||||
|
.map((h) => h.syncId)
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
folderSyncIds: deletedFolders
|
||||||
|
.map((f) => f.syncId)
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { hostAccess, hosts } from "../db/schema.js";
|
import { hostAccess, hosts } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
@@ -22,7 +23,7 @@ export class HostRepository {
|
|||||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.insert(hosts)
|
.insert(hosts)
|
||||||
.values(host)
|
.values({ syncId: randomUUID(), ...host })
|
||||||
.returning();
|
.returning();
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -34,7 +35,11 @@ export class HostRepository {
|
|||||||
): Promise<HostRecord> {
|
): Promise<HostRecord> {
|
||||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||||
const tempId = host.id ?? Date.now();
|
const tempId = host.id ?? Date.now();
|
||||||
const dataWithTempId = { ...host, id: tempId };
|
const dataWithTempId = {
|
||||||
|
syncId: randomUUID(),
|
||||||
|
...host,
|
||||||
|
id: tempId,
|
||||||
|
};
|
||||||
const encryptedHost = DataCrypto.encryptRecord(
|
const encryptedHost = DataCrypto.encryptRecord(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
dataWithTempId,
|
dataWithTempId,
|
||||||
@@ -221,16 +226,19 @@ export class HostRepository {
|
|||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteForUser(userId: string, hostId: number): Promise<boolean> {
|
async deleteForUser(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<{ syncId: string | null } | null> {
|
||||||
await this.deleteAccessForHost(hostId);
|
await this.deleteAccessForHost(hostId);
|
||||||
|
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(hosts)
|
.delete(hosts)
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.returning({ id: hosts.id });
|
.returning({ syncId: hosts.syncId });
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
return rows.length > 0;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull } from "drizzle-orm";
|
||||||
import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
|
import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
|
||||||
@@ -315,6 +315,34 @@ export class HostResolutionRepository {
|
|||||||
return rows[0]?.overrideCredentialId ?? null;
|
return rows[0]?.overrideCredentialId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the nearest assigned credential for a folder path, walking up
|
||||||
|
* through parent folders (e.g. "Switches / Floor1" falls back to
|
||||||
|
* "Switches" if the child folder has no credential of its own).
|
||||||
|
*/
|
||||||
|
async findFolderCredentialId(
|
||||||
|
userId: string,
|
||||||
|
folderPath: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const segments = folderPath.split(" / ").filter(Boolean);
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
|
||||||
|
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
|
||||||
|
for (let i = paths.length - 1; i >= 0; i--) {
|
||||||
|
const credentialId = byName.get(paths[i]);
|
||||||
|
if (credentialId) return credentialId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private decryptOne<T extends Record<string, unknown>>(
|
private decryptOne<T extends Record<string, unknown>>(
|
||||||
tableName: "ssh_data" | "ssh_credentials",
|
tableName: "ssh_data" | "ssh_credentials",
|
||||||
record: T | undefined,
|
record: T | undefined,
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
|
|||||||
|
|
||||||
async updateEnded(
|
async updateEnded(
|
||||||
id: number,
|
id: number,
|
||||||
input: { endedAt: string; duration: number | null },
|
input: {
|
||||||
|
endedAt: string;
|
||||||
|
duration: number | null;
|
||||||
|
terminatedByOwner?: boolean;
|
||||||
|
terminationReason?: string;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(sessionRecordings)
|
.update(sessionRecordings)
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
hosts,
|
||||||
|
sessionShareParticipants,
|
||||||
|
sessionShares,
|
||||||
|
users,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||||
|
export type SessionShareParticipantRecord =
|
||||||
|
typeof sessionShareParticipants.$inferSelect;
|
||||||
|
|
||||||
|
export type SessionShareType = "link" | "user";
|
||||||
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
|
export interface SessionShareCreateInput {
|
||||||
|
id: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId?: string | null;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId?: string | null;
|
||||||
|
linkToken?: string | null;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionShareWithHost extends SessionShareRecord {
|
||||||
|
hostName: string | null;
|
||||||
|
ownerUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||||
|
hostName: string | null;
|
||||||
|
ownerUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeShareFilter(now: string) {
|
||||||
|
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionShareRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||||
|
const [created] = await this.context.drizzle
|
||||||
|
.insert(sessionShares)
|
||||||
|
.values({
|
||||||
|
id: input.id,
|
||||||
|
hostId: input.hostId,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
protocol: input.protocol,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
tabInstanceId: input.tabInstanceId ?? null,
|
||||||
|
shareType: input.shareType,
|
||||||
|
targetUserId: input.targetUserId ?? null,
|
||||||
|
linkToken: input.linkToken ?? null,
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(eq(sessionShares.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveById(
|
||||||
|
id: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByLinkToken(
|
||||||
|
linkToken: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(
|
||||||
|
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveSharesForHost(
|
||||||
|
hostId: number,
|
||||||
|
ownerUserId: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.hostId, hostId),
|
||||||
|
eq(sessionShares.ownerUserId, ownerUserId),
|
||||||
|
activeShareFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSharesTargetingUser(
|
||||||
|
userId: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SharedWithMeRecord[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
share: sessionShares,
|
||||||
|
hostName: hosts.name,
|
||||||
|
ownerUsername: users.username,
|
||||||
|
})
|
||||||
|
.from(sessionShares)
|
||||||
|
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||||
|
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.shareType, "user"),
|
||||||
|
eq(sessionShares.targetUserId, userId),
|
||||||
|
activeShareFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row.share,
|
||||||
|
hostName: row.hostName,
|
||||||
|
ownerUsername: row.ownerUsername,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.id, shareId),
|
||||||
|
eq(sessionShares.ownerUserId, requestingUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
|
.where(eq(sessionShares.id, shareId))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sessionShares)
|
||||||
|
.where(lt(sessionShares.expiresAt, now))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchShareUsage(
|
||||||
|
shareId: string,
|
||||||
|
lastJoinedAt = new Date().toISOString(),
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await this.findById(shareId);
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({
|
||||||
|
lastJoinedAt,
|
||||||
|
joinCount: (current?.joinCount ?? 0) + 1,
|
||||||
|
})
|
||||||
|
.where(eq(sessionShares.id, shareId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordParticipantJoin(
|
||||||
|
shareId: string,
|
||||||
|
userId: string | null,
|
||||||
|
guestLabel: string | null,
|
||||||
|
): Promise<SessionShareParticipantRecord> {
|
||||||
|
const [created] = await this.context.drizzle
|
||||||
|
.insert(sessionShareParticipants)
|
||||||
|
.values({ shareId, userId, guestLabel })
|
||||||
|
.returning();
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessionShareParticipants)
|
||||||
|
.set({ leftAt: new Date().toISOString() })
|
||||||
|
.where(eq(sessionShareParticipants.id, participantId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sessionShares)
|
||||||
|
.where(eq(sessionShares.hostId, hostId))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, asc, eq, sql } from "drizzle-orm";
|
import { and, asc, eq, sql } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { snippetFolders, snippets } from "../db/schema.js";
|
import { snippetFolders, snippets } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ export class SnippetRepository {
|
|||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.insert(snippets)
|
.insert(snippets)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
content: input.content.trim(),
|
content: input.content.trim(),
|
||||||
@@ -343,6 +345,7 @@ export class SnippetRepository {
|
|||||||
|
|
||||||
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
||||||
await this.context.drizzle.insert(snippets).values({
|
await this.context.drizzle.insert(snippets).values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
name: snippet.name.trim(),
|
name: snippet.name.trim(),
|
||||||
content: snippet.content.trim(),
|
content: snippet.content.trim(),
|
||||||
@@ -377,6 +380,7 @@ export class SnippetRepository {
|
|||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.insert(snippetFolders)
|
.insert(snippetFolders)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId,
|
userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
color: color?.trim() || null,
|
color: color?.trim() || null,
|
||||||
@@ -452,19 +456,24 @@ export class SnippetRepository {
|
|||||||
return { status: "renamed" };
|
return { status: "renamed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFolder(userId: string, name: string): Promise<void> {
|
async deleteFolder(
|
||||||
|
userId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<{ syncId: string | null } | null> {
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(snippets)
|
.update(snippets)
|
||||||
.set({ folder: null })
|
.set({ folder: null })
|
||||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||||
|
|
||||||
await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(snippetFolders)
|
.delete(snippetFolders)
|
||||||
.where(
|
.where(
|
||||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||||
);
|
)
|
||||||
|
.returning({ syncId: snippetFolders.syncId });
|
||||||
|
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findFolderByName(
|
private async findFolderByName(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
|
import { syncTombstones } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||||
|
|
||||||
|
export type SyncEntityType =
|
||||||
|
| "hosts"
|
||||||
|
| "sshCredentials"
|
||||||
|
| "sshFolders"
|
||||||
|
| "snippets"
|
||||||
|
| "snippetFolders"
|
||||||
|
| "vaultProfiles"
|
||||||
|
| "dashboardServiceLinks"
|
||||||
|
| "homepageItems";
|
||||||
|
|
||||||
|
export class SyncTombstoneRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async record(
|
||||||
|
userId: string,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
syncId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!syncId) return;
|
||||||
|
await this.context.drizzle.insert(syncTombstones).values({
|
||||||
|
userId,
|
||||||
|
entityType,
|
||||||
|
syncId,
|
||||||
|
});
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordMany(
|
||||||
|
userId: string,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
syncIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const rows = syncIds.filter(Boolean);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
await this.context.drizzle
|
||||||
|
.insert(syncTombstones)
|
||||||
|
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSince(
|
||||||
|
userId: string,
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
since: string | null,
|
||||||
|
): Promise<SyncTombstoneRecord[]> {
|
||||||
|
const conditions = [
|
||||||
|
eq(syncTombstones.userId, userId),
|
||||||
|
eq(syncTombstones.entityType, entityType),
|
||||||
|
];
|
||||||
|
if (since) conditions.push(gt(syncTombstones.deletedAt, since));
|
||||||
|
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(syncTombstones)
|
||||||
|
.where(and(...conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { desc, eq, or } from "drizzle-orm";
|
import { desc, eq, or } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { vaultProfiles } from "../db/schema.js";
|
import { vaultProfiles } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
|
|||||||
const [created] = await this.context.drizzle
|
const [created] = await this.context.drizzle
|
||||||
.insert(vaultProfiles)
|
.insert(vaultProfiles)
|
||||||
.values({
|
.values({
|
||||||
|
syncId: randomUUID(),
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
|
|||||||
return updated ?? null;
|
return updated ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteById(id: number): Promise<boolean> {
|
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.delete(vaultProfiles)
|
.delete(vaultProfiles)
|
||||||
.where(eq(vaultProfiles.id, id))
|
.where(eq(vaultProfiles.id, id))
|
||||||
.returning({ id: vaultProfiles.id });
|
.returning({ syncId: vaultProfiles.syncId });
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length === 0) return null;
|
||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
return rows[0];
|
||||||
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
async deleteByUserId(userId: string): Promise<number> {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type AcmeSettings = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
domain: string;
|
domain: string;
|
||||||
email: string;
|
email: string;
|
||||||
challengeType: "http-webroot" | "dns-cloudflare";
|
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||||
cloudflareToken: string;
|
cloudflareToken: string;
|
||||||
lastIssuedAt: string | null;
|
lastIssuedAt: string | null;
|
||||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||||
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* challengeType:
|
* challengeType:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [http-webroot, dns-cloudflare]
|
* enum: [http-webroot, dns-cloudflare, manual]
|
||||||
* cloudflareToken:
|
* cloudflareToken:
|
||||||
* type: string
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
@@ -414,4 +414,159 @@ export function registerAcmeSSLRoutes(
|
|||||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/manual-ssl-upload:
|
||||||
|
* post:
|
||||||
|
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||||
|
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* certificate:
|
||||||
|
* type: string
|
||||||
|
* privateKey:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Certificate uploaded and installed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid or missing certificate/key.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Certificate installation failed.
|
||||||
|
*/
|
||||||
|
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
try {
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { certificate, privateKey } = req.body;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof certificate !== "string" ||
|
||||||
|
typeof privateKey !== "string" ||
|
||||||
|
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||||
|
!privateKey.includes("PRIVATE KEY")
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "A valid PEM certificate and private key are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||||
|
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||||
|
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync(
|
||||||
|
"openssl",
|
||||||
|
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"The provided certificate or private key is not valid PEM data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const certPubkey = execFileSync(
|
||||||
|
"openssl",
|
||||||
|
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
const keyPubkey = execFileSync(
|
||||||
|
"openssl",
|
||||||
|
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!certPubkey.equals(keyPubkey)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "The certificate and private key do not match" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||||
|
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||||
|
await fs.rename(tmpCertFile, certDest);
|
||||||
|
await fs.rename(tmpKeyFile, keyDest);
|
||||||
|
await fs.chmod(keyDest, 0o600);
|
||||||
|
await fs.chmod(certDest, 0o644);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpCertFile, { force: true });
|
||||||
|
await fs.rm(tmpKeyFile, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRepository = createCurrentSettingsRepository();
|
||||||
|
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||||
|
const current = existing ? JSON.parse(existing) : {};
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
challengeType: "manual",
|
||||||
|
lastIssuedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await settingsRepository.set(
|
||||||
|
"acme_ssl_settings",
|
||||||
|
JSON.stringify(updated),
|
||||||
|
);
|
||||||
|
|
||||||
|
authLogger.info("Manual SSL certificate installed", {
|
||||||
|
operation: "manual_ssl_installed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "manual_ssl_upload",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ success: true }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, ...(await getAcmeSettings()) });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
authLogger.error("Manual SSL certificate upload failed", err);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor?.username ?? userId,
|
||||||
|
action: "manual_ssl_upload",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ error: message }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Certificate installation failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -642,6 +643,13 @@ router.delete(
|
|||||||
userId,
|
userId,
|
||||||
credentialId,
|
credentialId,
|
||||||
);
|
);
|
||||||
|
if (credentialToDelete.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"sshCredentials",
|
||||||
|
credentialToDelete.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||||
// auth the host still has (or lose the stale credential copy).
|
// auth the host still has (or lose the stale credential copy).
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js";
|
|||||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
import { isNonEmptyString } from "./host-normalizers.js";
|
import { isNonEmptyString } from "./host-normalizers.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
|
import {
|
||||||
|
createCurrentDashboardServiceLinkRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
export const dashboardServiceLinksRouter = express.Router();
|
export const dashboardServiceLinksRouter = express.Router();
|
||||||
|
|
||||||
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
|
|||||||
return res.status(404).json({ error: "Not found" });
|
return res.status(404).json({ error: "Not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
const deleted =
|
||||||
userId,
|
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||||
id,
|
userId,
|
||||||
);
|
id,
|
||||||
|
);
|
||||||
|
if (deleted?.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"dashboardServiceLinks",
|
||||||
|
deleted.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||||
res.json({ message: "Service link deleted" });
|
res.json({ message: "Service link deleted" });
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { homepageLogger } from "../../utils/logger.js";
|
import { homepageLogger } from "../../utils/logger.js";
|
||||||
import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
|
import {
|
||||||
|
createCurrentHomepageItemRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
|
||||||
export const homepageItemsRouter = express.Router();
|
export const homepageItemsRouter = express.Router();
|
||||||
@@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
|||||||
return res.status(404).json({ error: "Not found" });
|
return res.status(404).json({ error: "Not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await itemRepository.deleteForUser(userId, id);
|
const deleted = await itemRepository.deleteForUser(userId, id);
|
||||||
|
if (deleted?.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"homepageItems",
|
||||||
|
deleted.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
res.json({ message: "Homepage item deleted" });
|
res.json({ message: "Homepage item deleted" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
homepageLogger.error("Failed to delete homepage item", err);
|
homepageLogger.error("Failed to delete homepage item", err);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCommandHistoryRepository,
|
createCurrentCommandHistoryRepository,
|
||||||
|
createCurrentCredentialRepository,
|
||||||
createCurrentFileManagerBookmarkRepository,
|
createCurrentFileManagerBookmarkRepository,
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentSessionRecordingRepository,
|
createCurrentSessionRecordingRepository,
|
||||||
createCurrentTransferRecentRepository,
|
createCurrentTransferRecentRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { isNonEmptyString } from "./host-normalizers.js";
|
import { isNonEmptyString } from "./host-normalizers.js";
|
||||||
|
|
||||||
@@ -138,7 +140,7 @@ export function registerHostFolderRoutes(
|
|||||||
* /host/folders/metadata:
|
* /host/folders/metadata:
|
||||||
* put:
|
* put:
|
||||||
* summary: Update folder metadata
|
* summary: Update folder metadata
|
||||||
* description: Updates the metadata (color, icon) of a folder.
|
* description: Updates the metadata (color, icon, assigned credential) of a folder.
|
||||||
* tags:
|
* tags:
|
||||||
* - SSH
|
* - SSH
|
||||||
* requestBody:
|
* requestBody:
|
||||||
@@ -154,6 +156,9 @@ export function registerHostFolderRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* icon:
|
* icon:
|
||||||
* type: string
|
* type: string
|
||||||
|
* credentialId:
|
||||||
|
* type: integer
|
||||||
|
* nullable: true
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Folder metadata updated successfully.
|
* description: Folder metadata updated successfully.
|
||||||
@@ -167,19 +172,46 @@ export function registerHostFolderRoutes(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { name, color, icon } = req.body;
|
const { name, color, icon, credentialId } = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !name) {
|
if (!isNonEmptyString(userId) || !name) {
|
||||||
return res.status(400).json({ error: "Folder name is required" });
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedCredentialId =
|
||||||
|
credentialId === undefined
|
||||||
|
? undefined
|
||||||
|
: credentialId === null || credentialId === ""
|
||||||
|
? null
|
||||||
|
: Number(credentialId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedCredentialId !== undefined &&
|
||||||
|
normalizedCredentialId !== null &&
|
||||||
|
!Number.isInteger(normalizedCredentialId)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Invalid credential ID" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (normalizedCredentialId) {
|
||||||
|
const credential =
|
||||||
|
await createCurrentCredentialRepository().findByIdForUser(
|
||||||
|
userId,
|
||||||
|
normalizedCredentialId,
|
||||||
|
);
|
||||||
|
if (!credential) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { folder, created } =
|
const { folder, created } =
|
||||||
await createCurrentHostFolderRepository().upsertMetadata(
|
await createCurrentHostFolderRepository().upsertMetadata(
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
normalizedCredentialId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -287,9 +319,17 @@ export function registerHostFolderRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
const { hostSyncIds, folderSyncIds } =
|
||||||
|
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
);
|
||||||
|
const tombstoneRepository = createCurrentSyncTombstoneRepository();
|
||||||
|
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
|
||||||
|
await tombstoneRepository.recordMany(
|
||||||
userId,
|
userId,
|
||||||
folderName,
|
"sshFolders",
|
||||||
|
folderSyncIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../../hosts/credential-username.js";
|
} from "../../hosts/credential-username.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCommandHistoryRepository,
|
createCurrentCommandHistoryRepository,
|
||||||
|
createCurrentCredentialRepository,
|
||||||
createCurrentFileManagerBookmarkRepository,
|
createCurrentFileManagerBookmarkRepository,
|
||||||
createCurrentOpksshTokenRepository,
|
createCurrentOpksshTokenRepository,
|
||||||
createCurrentRecentActivityRepository,
|
createCurrentRecentActivityRepository,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
@@ -174,6 +176,7 @@ router.post(
|
|||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
|
allowSessionSharing,
|
||||||
showTerminalInSidebar,
|
showTerminalInSidebar,
|
||||||
showFileManagerInSidebar,
|
showFileManagerInSidebar,
|
||||||
showTunnelInSidebar,
|
showTunnelInSidebar,
|
||||||
@@ -199,6 +202,7 @@ router.post(
|
|||||||
socks5Username,
|
socks5Username,
|
||||||
socks5Password,
|
socks5Password,
|
||||||
socks5ProxyChain,
|
socks5ProxyChain,
|
||||||
|
connectionOrigin,
|
||||||
portKnockSequence,
|
portKnockSequence,
|
||||||
overrideCredentialUsername,
|
overrideCredentialUsername,
|
||||||
macAddress,
|
macAddress,
|
||||||
@@ -287,6 +291,7 @@ router.post(
|
|||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
|
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||||
@@ -328,6 +333,10 @@ router.post(
|
|||||||
socks5ProxyChain: socks5ProxyChain
|
socks5ProxyChain: socks5ProxyChain
|
||||||
? JSON.stringify(socks5ProxyChain)
|
? JSON.stringify(socks5ProxyChain)
|
||||||
: null,
|
: null,
|
||||||
|
connectionOrigin:
|
||||||
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||||
|
? connectionOrigin
|
||||||
|
: null,
|
||||||
macAddress: macAddress || null,
|
macAddress: macAddress || null,
|
||||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
@@ -814,6 +823,7 @@ router.put(
|
|||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
|
allowSessionSharing,
|
||||||
showTerminalInSidebar,
|
showTerminalInSidebar,
|
||||||
showFileManagerInSidebar,
|
showFileManagerInSidebar,
|
||||||
showTunnelInSidebar,
|
showTunnelInSidebar,
|
||||||
@@ -839,6 +849,7 @@ router.put(
|
|||||||
socks5Username,
|
socks5Username,
|
||||||
socks5Password,
|
socks5Password,
|
||||||
socks5ProxyChain,
|
socks5ProxyChain,
|
||||||
|
connectionOrigin,
|
||||||
portKnockSequence,
|
portKnockSequence,
|
||||||
overrideCredentialUsername,
|
overrideCredentialUsername,
|
||||||
macAddress,
|
macAddress,
|
||||||
@@ -924,6 +935,7 @@ router.put(
|
|||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
|
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||||
@@ -965,6 +977,10 @@ router.put(
|
|||||||
socks5ProxyChain: socks5ProxyChain
|
socks5ProxyChain: socks5ProxyChain
|
||||||
? JSON.stringify(socks5ProxyChain)
|
? JSON.stringify(socks5ProxyChain)
|
||||||
: null,
|
: null,
|
||||||
|
connectionOrigin:
|
||||||
|
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||||
|
? connectionOrigin
|
||||||
|
: null,
|
||||||
macAddress: macAddress || null,
|
macAddress: macAddress || null,
|
||||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
@@ -1482,7 +1498,7 @@ router.get(
|
|||||||
* name: field
|
* name: field
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [password, sudoPassword, vncPassword]
|
* enum: [password, sudoPassword, vncPassword, key, keyPassword]
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The requested password value.
|
* description: The requested password value.
|
||||||
@@ -1498,7 +1514,15 @@ router.get(
|
|||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const field = (req.query.field as string) || "password";
|
const field = (req.query.field as string) || "password";
|
||||||
|
|
||||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
if (
|
||||||
|
![
|
||||||
|
"password",
|
||||||
|
"sudoPassword",
|
||||||
|
"vncPassword",
|
||||||
|
"key",
|
||||||
|
"keyPassword",
|
||||||
|
].includes(field)
|
||||||
|
) {
|
||||||
return res.status(400).json({ error: "Invalid field" });
|
return res.status(400).json({ error: "Invalid field" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1726,9 +1750,16 @@ router.get(
|
|||||||
* /host/db/hosts/export:
|
* /host/db/hosts/export:
|
||||||
* get:
|
* get:
|
||||||
* summary: Export all SSH hosts
|
* summary: Export all SSH hosts
|
||||||
* description: Exports all SSH hosts for the current user with decrypted credentials.
|
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
|
||||||
* tags:
|
* tags:
|
||||||
* - SSH
|
* - SSH
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: share
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Set to "1" to export without embedded secrets.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: All exported SSH hosts.
|
* description: All exported SSH hosts.
|
||||||
@@ -1743,6 +1774,7 @@ router.get(
|
|||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const shareMode = req.query.share === "1" || req.query.share === "true";
|
||||||
|
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
return res.status(400).json({ error: "Invalid userId" });
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
@@ -1753,10 +1785,12 @@ router.get(
|
|||||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||||
|
|
||||||
const exportedHosts = [];
|
const exportedHosts = [];
|
||||||
|
const usedCredentialIds = new Set<number>();
|
||||||
|
|
||||||
for (const host of allHosts) {
|
for (const host of allHosts) {
|
||||||
const resolvedHost =
|
const resolvedHost = shareMode
|
||||||
(await resolveHostCredentials(host, userId)) || host;
|
? host
|
||||||
|
: (await resolveHostCredentials(host, userId)) || host;
|
||||||
|
|
||||||
const exportedConnectionType =
|
const exportedConnectionType =
|
||||||
(resolvedHost.connectionType as string) || "ssh";
|
(resolvedHost.connectionType as string) || "ssh";
|
||||||
@@ -1770,7 +1804,7 @@ router.get(
|
|||||||
ip: resolvedHost.ip,
|
ip: resolvedHost.ip,
|
||||||
port: resolvedHost.port,
|
port: resolvedHost.port,
|
||||||
username: resolvedHost.username,
|
username: resolvedHost.username,
|
||||||
password: resolvedHost.password || null,
|
password: shareMode ? null : resolvedHost.password || null,
|
||||||
folder: resolvedHost.folder,
|
folder: resolvedHost.folder,
|
||||||
tags:
|
tags:
|
||||||
typeof resolvedHost.tags === "string"
|
typeof resolvedHost.tags === "string"
|
||||||
@@ -1793,8 +1827,8 @@ router.get(
|
|||||||
: {
|
: {
|
||||||
...baseExportData,
|
...baseExportData,
|
||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
key: resolvedHost.key || null,
|
key: shareMode ? null : resolvedHost.key || null,
|
||||||
keyPassword: resolvedHost.keyPassword || null,
|
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
||||||
keyType: resolvedHost.keyType || null,
|
keyType: resolvedHost.keyType || null,
|
||||||
credentialId: resolvedHost.credentialId || null,
|
credentialId: resolvedHost.credentialId || null,
|
||||||
overrideCredentialUsername:
|
overrideCredentialUsername:
|
||||||
@@ -1811,7 +1845,9 @@ router.get(
|
|||||||
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
|
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
|
||||||
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
|
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
|
||||||
defaultPath: resolvedHost.defaultPath,
|
defaultPath: resolvedHost.defaultPath,
|
||||||
sudoPassword: resolvedHost.sudoPassword || null,
|
sudoPassword: shareMode
|
||||||
|
? null
|
||||||
|
: resolvedHost.sudoPassword || null,
|
||||||
tunnelConnections: resolvedHost.tunnelConnections
|
tunnelConnections: resolvedHost.tunnelConnections
|
||||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||||
: [],
|
: [],
|
||||||
@@ -1839,22 +1875,92 @@ router.get(
|
|||||||
socks5Host: resolvedHost.socks5Host || null,
|
socks5Host: resolvedHost.socks5Host || null,
|
||||||
socks5Port: resolvedHost.socks5Port || null,
|
socks5Port: resolvedHost.socks5Port || null,
|
||||||
socks5Username: resolvedHost.socks5Username || null,
|
socks5Username: resolvedHost.socks5Username || null,
|
||||||
socks5Password: resolvedHost.socks5Password || null,
|
socks5Password: shareMode
|
||||||
|
? null
|
||||||
|
: resolvedHost.socks5Password || null,
|
||||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
shareMode &&
|
||||||
|
!isRemoteDesktop &&
|
||||||
|
resolvedHost.authType === "credential" &&
|
||||||
|
resolvedHost.credentialId
|
||||||
|
) {
|
||||||
|
usedCredentialIds.add(resolvedHost.credentialId as number);
|
||||||
|
}
|
||||||
|
|
||||||
exportedHosts.push(exportData);
|
exportedHosts.push(exportData);
|
||||||
}
|
}
|
||||||
|
|
||||||
sshLogger.success("All hosts exported with decrypted credentials", {
|
if (!shareMode) {
|
||||||
operation: "hosts_export_all",
|
sshLogger.success("All hosts exported with decrypted credentials", {
|
||||||
|
operation: "hosts_export_all",
|
||||||
|
count: exportedHosts.length,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ hosts: exportedHosts });
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedCredentials: Record<string, unknown>[] = [];
|
||||||
|
if (usedCredentialIds.size > 0) {
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const ownedCredentials =
|
||||||
|
await credentialRepository.listDecryptedByUserId(userId);
|
||||||
|
const credentialById = new Map(
|
||||||
|
ownedCredentials.map((credential) => [credential.id, credential]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of exportedHosts as Record<string, unknown>[]) {
|
||||||
|
const credentialId = host.credentialId as number | null;
|
||||||
|
if (!credentialId) continue;
|
||||||
|
const credential = credentialById.get(credentialId);
|
||||||
|
if (!credential) continue;
|
||||||
|
|
||||||
|
host.credentialAlias = credential.name;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!exportedCredentials.some(
|
||||||
|
(entry) => entry.alias === credential.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
exportedCredentials.push({
|
||||||
|
alias: credential.name,
|
||||||
|
name: credential.name,
|
||||||
|
description: credential.description || null,
|
||||||
|
folder: credential.folder || null,
|
||||||
|
tags:
|
||||||
|
typeof credential.tags === "string"
|
||||||
|
? credential.tags.split(",").filter(Boolean)
|
||||||
|
: [],
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username || null,
|
||||||
|
keyType: credential.keyType || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const host of exportedHosts as Record<string, unknown>[]) {
|
||||||
|
delete host.credentialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshLogger.success("All hosts exported for sharing without secrets", {
|
||||||
|
operation: "hosts_export_all_share",
|
||||||
count: exportedHosts.length,
|
count: exportedHosts.length,
|
||||||
|
credentialCount: exportedCredentials.length,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ hosts: exportedHosts });
|
res.json({
|
||||||
|
version: "1",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
credentials: exportedCredentials,
|
||||||
|
hosts: exportedHosts,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to export all SSH hosts", err, {
|
sshLogger.error("Failed to export all SSH hosts", err, {
|
||||||
operation: "hosts_export_all",
|
operation: "hosts_export_all",
|
||||||
@@ -1959,6 +2065,13 @@ router.delete(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
|
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
|
||||||
|
if (hostToDelete.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"hosts",
|
||||||
|
hostToDelete.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
databaseLogger.success("SSH host deleted", {
|
databaseLogger.success("SSH host deleted", {
|
||||||
operation: "host_delete_success",
|
operation: "host_delete_success",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
|||||||
import {
|
import {
|
||||||
getCurrentSettingValue,
|
getCurrentSettingValue,
|
||||||
createCurrentOpenTabRepository,
|
createCurrentOpenTabRepository,
|
||||||
|
createCurrentSessionShareRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* /open-tabs/active-sessions:
|
* /open-tabs/active-sessions:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get all active backend sessions for the current user
|
* summary: Get all active backend sessions for the current user
|
||||||
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
|
* description: >
|
||||||
|
* Returns live terminal sessions from the session manager, both sessions the
|
||||||
|
* caller owns and SSH sessions shared to the caller by another user (via
|
||||||
|
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||||
* tags:
|
* tags:
|
||||||
* - Open Tabs
|
* - Open Tabs
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List of active sessions.
|
* description: List of active sessions (own and shared-with-me).
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* createdAt:
|
* createdAt:
|
||||||
* type: number
|
* type: number
|
||||||
|
* isOwnSession:
|
||||||
|
* type: boolean
|
||||||
|
* sharedByUsername:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* shareId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/active-sessions",
|
"/active-sessions",
|
||||||
@@ -309,17 +324,46 @@ router.get(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
const sessions = sessionManager.getUserSessions(userId);
|
const ownSessions = sessionManager.getUserSessions(userId);
|
||||||
return res.json(
|
const result = ownSessions.map((s) => ({
|
||||||
sessions.map((s) => ({
|
sessionId: s.id,
|
||||||
sessionId: s.id,
|
hostId: s.hostId,
|
||||||
hostId: s.hostId,
|
hostName: s.hostName,
|
||||||
hostName: s.hostName,
|
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
isConnected: s.isConnected,
|
||||||
isConnected: s.isConnected,
|
createdAt: s.createdAt,
|
||||||
createdAt: s.createdAt,
|
isOwnSession: true,
|
||||||
})),
|
sharedByUsername: null as string | null,
|
||||||
);
|
permissionLevel: null as string | null,
|
||||||
|
shareId: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sharedWithMe =
|
||||||
|
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
for (const share of sharedWithMe) {
|
||||||
|
if (share.protocol !== "ssh") continue;
|
||||||
|
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||||
|
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||||
|
result.push({
|
||||||
|
sessionId: sharedSession.id,
|
||||||
|
hostId: sharedSession.hostId,
|
||||||
|
hostName: sharedSession.hostName,
|
||||||
|
tabInstanceId:
|
||||||
|
sharedSession.attachedTabInstanceId ??
|
||||||
|
sharedSession.tabInstanceId ??
|
||||||
|
null,
|
||||||
|
isConnected: sharedSession.isConnected,
|
||||||
|
createdAt: sharedSession.createdAt,
|
||||||
|
isOwnSession: false,
|
||||||
|
sharedByUsername: share.ownerUsername,
|
||||||
|
permissionLevel: share.permissionLevel,
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.error("Failed to get active sessions", e, {
|
databaseLogger.error("Failed to get active sessions", e, {
|
||||||
operation: "get_active_sessions",
|
operation: "get_active_sessions",
|
||||||
|
|||||||
@@ -488,16 +488,51 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
|
|
||||||
async function resolveIp(g: GuestBase): Promise<string | null> {
|
async function resolveIp(g: GuestBase): Promise<string | null> {
|
||||||
if (g.type === "lxc") {
|
if (g.type === "lxc") {
|
||||||
|
let configIp: string | null = null;
|
||||||
try {
|
try {
|
||||||
const cfgJson = await execCommand(
|
const cfgJson = await execCommand(
|
||||||
client,
|
client,
|
||||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
||||||
8000,
|
8000,
|
||||||
);
|
);
|
||||||
return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
configIp = null;
|
||||||
}
|
}
|
||||||
|
if (configIp) return configIp;
|
||||||
|
// Static config parsing found nothing (e.g. net0 uses ip=dhcp).
|
||||||
|
// Fall back to the live interface list for running containers.
|
||||||
|
if (g.status === "running") {
|
||||||
|
try {
|
||||||
|
const ifRaw = await execCommand(
|
||||||
|
client,
|
||||||
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
const data = JSON.parse(ifRaw);
|
||||||
|
const entries: Array<Record<string, unknown>> = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: [];
|
||||||
|
const allIps: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "lo") continue;
|
||||||
|
const inet = entry.inet;
|
||||||
|
if (typeof inet !== "string") continue;
|
||||||
|
const m = inet.match(/^(\d{1,3}(?:\.\d{1,3}){3})\/\d+$/);
|
||||||
|
if (m && !m[1].startsWith("127.")) allIps.push(m[1]);
|
||||||
|
}
|
||||||
|
if (allIps.length) {
|
||||||
|
for (const prefix of config.preferredPrefixes) {
|
||||||
|
const match = allIps.find((ip) => ip.startsWith(prefix));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return allIps[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Guest not running or interfaces unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (g.type === "qemu" && g.status === "running") {
|
if (g.type === "qemu" && g.status === "running") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../../utils/permission-catalog.js";
|
} from "../../utils/permission-catalog.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentHostFolderRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
@@ -311,6 +312,225 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/folder/share:
|
||||||
|
* post:
|
||||||
|
* summary: Share all hosts in a folder
|
||||||
|
* description: Shares every host within a folder (and its subfolders) with one or more users and/or roles at a permission level. Only hosts owned by the caller are shared; skips hosts the caller may not share.
|
||||||
|
* tags:
|
||||||
|
* - RBAC
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [folder, targets]
|
||||||
|
* properties:
|
||||||
|
* folder:
|
||||||
|
* type: string
|
||||||
|
* targets:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* type:
|
||||||
|
* type: string
|
||||||
|
* enum: [user, role]
|
||||||
|
* id:
|
||||||
|
* oneOf:
|
||||||
|
* - type: string
|
||||||
|
* - type: integer
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* enum: [connect, view, edit, manage]
|
||||||
|
* durationHours:
|
||||||
|
* type: number
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Folder shared successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 404:
|
||||||
|
* description: Folder has no hosts.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to share folder.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/folder/share",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
const userId = req.userId!;
|
||||||
|
const { folder } = req.body ?? {};
|
||||||
|
|
||||||
|
if (!isNonEmptyString(folder)) {
|
||||||
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targets = parseShareTargets(req.body ?? {});
|
||||||
|
if (!targets) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"targets must be a non-empty array of { type: 'user'|'role', id } entries",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { durationHours, permissionLevel = "connect" } = req.body;
|
||||||
|
|
||||||
|
if (!isSharePermissionLevel(permissionLevel)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid permission level",
|
||||||
|
validLevels: SHARE_PERMISSION_LEVELS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const roleRepository = createCurrentRoleRepository();
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target.type === "user") {
|
||||||
|
const targetUser = await userRepository.findById(target.id as string);
|
||||||
|
if (!targetUser) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Target user not found",
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetRole = await roleRepository.findRoleById(
|
||||||
|
target.id as number,
|
||||||
|
);
|
||||||
|
if (!targetRole) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Target role not found",
|
||||||
|
targetId: target.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsInFolder =
|
||||||
|
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||||
|
userId,
|
||||||
|
folder,
|
||||||
|
);
|
||||||
|
if (hostsInFolder.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Folder has no hosts" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = expiryFromDuration(durationHours);
|
||||||
|
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
|
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||||
|
|
||||||
|
const hostResults: Array<{
|
||||||
|
hostId: number;
|
||||||
|
shared: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const host of hostsInFolder) {
|
||||||
|
if (targets.some((t) => t.type === "user" && t.id === host.userId)) {
|
||||||
|
hostResults.push({
|
||||||
|
hostId: host.id,
|
||||||
|
shared: false,
|
||||||
|
reason: "owner",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharing = await canManageHostSharing(userId, host.id);
|
||||||
|
if (!sharing.allowed) {
|
||||||
|
hostResults.push({
|
||||||
|
hostId: host.id,
|
||||||
|
shared: false,
|
||||||
|
reason: "forbidden",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const accessGrant = await rbacAccessRepository.upsertHostAccess({
|
||||||
|
hostId: host.id,
|
||||||
|
grantedBy: userId,
|
||||||
|
permissionLevel,
|
||||||
|
expiresAt,
|
||||||
|
...(target.type === "user"
|
||||||
|
? {
|
||||||
|
targetType: "user" as const,
|
||||||
|
targetUserId: target.id as string,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
targetType: "role" as const,
|
||||||
|
targetRoleId: target.id as number,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (target.type === "user") {
|
||||||
|
await secretsManager.snapshotForUser(
|
||||||
|
accessGrant.id,
|
||||||
|
host.id,
|
||||||
|
target.id as string,
|
||||||
|
host.userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await secretsManager.snapshotForRole(
|
||||||
|
accessGrant.id,
|
||||||
|
host.id,
|
||||||
|
target.id as number,
|
||||||
|
host.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (snapshotError) {
|
||||||
|
databaseLogger.warn("Share created but secret snapshot failed", {
|
||||||
|
operation: "rbac_folder_share_snapshot_failed",
|
||||||
|
hostId: host.id,
|
||||||
|
accessId: accessGrant.id,
|
||||||
|
error:
|
||||||
|
snapshotError instanceof Error
|
||||||
|
? snapshotError.message
|
||||||
|
: "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hostResults.push({ hostId: host.id, shared: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedCount = hostResults.filter((r) => r.shared).length;
|
||||||
|
|
||||||
|
databaseLogger.success("Folder shared successfully", {
|
||||||
|
operation: "rbac_folder_share_success",
|
||||||
|
userId,
|
||||||
|
folder,
|
||||||
|
hostsShared: sharedCount,
|
||||||
|
targets: targets.length,
|
||||||
|
permissionLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Folder shared successfully",
|
||||||
|
permissionLevel,
|
||||||
|
expiresAt,
|
||||||
|
hostsShared: sharedCount,
|
||||||
|
hostsTotal: hostsInFolder.length,
|
||||||
|
hostResults,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to share folder", error, {
|
||||||
|
operation: "share_folder",
|
||||||
|
folder,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to share folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/host/{id}/access/{accessId}:
|
* /rbac/host/{id}/access/{accessId}:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
createCurrentSnippetRepository,
|
createCurrentSnippetRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -400,7 +401,17 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
const folderName = decodeURIComponent(name);
|
const folderName = decodeURIComponent(name);
|
||||||
|
|
||||||
await createCurrentSnippetRepository().deleteFolder(userId, folderName);
|
const deletedFolder = await createCurrentSnippetRepository().deleteFolder(
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
);
|
||||||
|
if (deletedFolder?.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"snippetFolders",
|
||||||
|
deletedFolder.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
authLogger.success(
|
authLogger.success(
|
||||||
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
||||||
@@ -1241,6 +1252,14 @@ router.delete(
|
|||||||
return res.status(404).json({ error: "Snippet not found" });
|
return res.status(404).json({ error: "Snippet not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"snippets",
|
||||||
|
existing.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
databaseLogger.info("Command snippet deleted", {
|
databaseLogger.info("Command snippet deleted", {
|
||||||
operation: "snippet_delete",
|
operation: "snippet_delete",
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
hosts,
|
||||||
|
sshCredentials,
|
||||||
|
sshFolders,
|
||||||
|
snippets,
|
||||||
|
snippetFolders,
|
||||||
|
vaultProfiles,
|
||||||
|
dashboardServiceLinks,
|
||||||
|
homepageItems,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import {
|
||||||
|
createCurrentRepositoryContext,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
|
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
|
// Encrypted tables need DataCrypto to translate between the wire payload
|
||||||
|
// (plaintext) and the stored row (encrypted). Everything else is stored
|
||||||
|
// and synced as-is.
|
||||||
|
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
|
||||||
|
hosts: "ssh_data",
|
||||||
|
sshCredentials: "ssh_credentials",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EntityConfig {
|
||||||
|
table:
|
||||||
|
| typeof hosts
|
||||||
|
| typeof sshCredentials
|
||||||
|
| typeof sshFolders
|
||||||
|
| typeof snippets
|
||||||
|
| typeof snippetFolders
|
||||||
|
| typeof vaultProfiles
|
||||||
|
| typeof dashboardServiceLinks
|
||||||
|
| typeof homepageItems;
|
||||||
|
// Fields that only make sense on the device that created the row, or
|
||||||
|
// that are managed elsewhere and must never be overwritten by a sync
|
||||||
|
// payload from the other side.
|
||||||
|
readOnlyFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
||||||
|
hosts: {
|
||||||
|
table: hosts,
|
||||||
|
readOnlyFields: ["connectionOrigin"],
|
||||||
|
},
|
||||||
|
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
|
||||||
|
sshFolders: { table: sshFolders, readOnlyFields: [] },
|
||||||
|
snippets: { table: snippets, readOnlyFields: [] },
|
||||||
|
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
|
||||||
|
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
|
||||||
|
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
|
||||||
|
homepageItems: { table: homepageItems, readOnlyFields: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||||
|
|
||||||
|
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||||
|
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUserDataKey(userId: string): Buffer {
|
||||||
|
return DataCrypto.validateUserAccess(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptIfNeeded(
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
userId: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||||
|
if (!tableName) return row;
|
||||||
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||||
|
if (!userDataKey) return row;
|
||||||
|
return DataCrypto.decryptRecord(
|
||||||
|
tableName,
|
||||||
|
row,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptIfNeeded(
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
userId: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||||
|
if (!tableName) return row;
|
||||||
|
const userDataKey = requireUserDataKey(userId);
|
||||||
|
return DataCrypto.encryptRecord(
|
||||||
|
tableName,
|
||||||
|
row,
|
||||||
|
userId,
|
||||||
|
userDataKey,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripWritePayload(
|
||||||
|
entityType: SyncEntityType,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const { readOnlyFields } = ENTITY_CONFIG[entityType];
|
||||||
|
const clean = { ...payload };
|
||||||
|
delete clean.id;
|
||||||
|
delete clean.userId;
|
||||||
|
delete clean.syncId;
|
||||||
|
for (const field of readOnlyFields) delete clean[field];
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /sync/{entityType}:
|
||||||
|
* get:
|
||||||
|
* summary: Pull synced rows for an entity type
|
||||||
|
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
|
||||||
|
* tags:
|
||||||
|
* - Sync
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: entityType
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: since
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Rows updated since the given timestamp.
|
||||||
|
* 400:
|
||||||
|
* description: Unknown entity type.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch rows.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:entityType",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const entityType = req.params.entityType;
|
||||||
|
if (!isValidEntityType(entityType)) {
|
||||||
|
return res.status(400).json({ error: "Unknown entity type" });
|
||||||
|
}
|
||||||
|
const since =
|
||||||
|
typeof req.query.since === "string" && req.query.since
|
||||||
|
? req.query.since
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { table } = ENTITY_CONFIG[entityType];
|
||||||
|
const context = createCurrentRepositoryContext();
|
||||||
|
const conditions = [eq(table.userId, userId)];
|
||||||
|
if (since && "updatedAt" in table) {
|
||||||
|
conditions.push(gt((table as typeof hosts).updatedAt, since));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(table as typeof hosts)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
const decrypted = rows.map((row) =>
|
||||||
|
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ rows: decrypted });
|
||||||
|
} catch (err) {
|
||||||
|
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
|
||||||
|
operation: "sync_pull",
|
||||||
|
entityType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to fetch rows" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /sync/{entityType}:
|
||||||
|
* post:
|
||||||
|
* summary: Upsert a synced row by syncId
|
||||||
|
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
|
||||||
|
* tags:
|
||||||
|
* - Sync
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: entityType
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Row upserted.
|
||||||
|
* 400:
|
||||||
|
* description: Unknown entity type or missing syncId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to upsert row.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:entityType",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const entityType = req.params.entityType;
|
||||||
|
if (!isValidEntityType(entityType)) {
|
||||||
|
return res.status(400).json({ error: "Unknown entity type" });
|
||||||
|
}
|
||||||
|
const payload = req.body?.row;
|
||||||
|
const syncId = payload?.syncId;
|
||||||
|
if (!payload || typeof syncId !== "string" || !syncId) {
|
||||||
|
return res.status(400).json({ error: "Missing row.syncId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { table } = ENTITY_CONFIG[entityType];
|
||||||
|
const context = createCurrentRepositoryContext();
|
||||||
|
|
||||||
|
const existingRows = await context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(table as typeof hosts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((table as typeof hosts).syncId, syncId),
|
||||||
|
eq(table.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const writePayload = stripWritePayload(entityType, payload);
|
||||||
|
const encryptedPayload = encryptIfNeeded(
|
||||||
|
entityType,
|
||||||
|
writePayload,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resultRow: Record<string, unknown>;
|
||||||
|
if (existing) {
|
||||||
|
const updatedRows = await context.drizzle
|
||||||
|
.update(table as typeof hosts)
|
||||||
|
.set(encryptedPayload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((table as typeof hosts).id, existing.id as number),
|
||||||
|
eq(table.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
const insertedRows = await context.drizzle
|
||||||
|
.insert(table as typeof hosts)
|
||||||
|
.values({
|
||||||
|
...encryptedPayload,
|
||||||
|
userId,
|
||||||
|
syncId,
|
||||||
|
} as typeof hosts.$inferInsert)
|
||||||
|
.returning();
|
||||||
|
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DatabaseSaveTrigger.forceSave("sync_upsert");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
row: decryptIfNeeded(entityType, resultRow, userId),
|
||||||
|
created: !existing,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
|
||||||
|
operation: "sync_upsert",
|
||||||
|
entityType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to upsert row" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /sync/{entityType}/tombstones:
|
||||||
|
* get:
|
||||||
|
* summary: Pull deletion tombstones for an entity type
|
||||||
|
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
|
||||||
|
* tags:
|
||||||
|
* - Sync
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: entityType
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: since
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Tombstones recorded since the given timestamp.
|
||||||
|
* 400:
|
||||||
|
* description: Unknown entity type.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch tombstones.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:entityType/tombstones",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const entityType = req.params.entityType;
|
||||||
|
if (!isValidEntityType(entityType)) {
|
||||||
|
return res.status(400).json({ error: "Unknown entity type" });
|
||||||
|
}
|
||||||
|
const since =
|
||||||
|
typeof req.query.since === "string" && req.query.since
|
||||||
|
? req.query.since
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
|
||||||
|
userId,
|
||||||
|
entityType,
|
||||||
|
since,
|
||||||
|
);
|
||||||
|
res.json({ tombstones });
|
||||||
|
} catch (err) {
|
||||||
|
databaseLogger.error(
|
||||||
|
`Failed to fetch sync tombstones for ${entityType}`,
|
||||||
|
err,
|
||||||
|
{ operation: "sync_tombstones_pull", entityType, userId },
|
||||||
|
);
|
||||||
|
res.status(500).json({ error: "Failed to fetch tombstones" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /sync/tombstones:
|
||||||
|
* post:
|
||||||
|
* summary: Report a deletion from the other side of a sync pair
|
||||||
|
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
|
||||||
|
* tags:
|
||||||
|
* - Sync
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Deletion applied (or row already absent).
|
||||||
|
* 400:
|
||||||
|
* description: Unknown entity type or missing syncId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to apply deletion.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/tombstones",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const entityType = req.body?.entityType;
|
||||||
|
const syncId = req.body?.syncId;
|
||||||
|
if (
|
||||||
|
!isValidEntityType(entityType) ||
|
||||||
|
typeof syncId !== "string" ||
|
||||||
|
!syncId
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Missing entityType or syncId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { table } = ENTITY_CONFIG[entityType];
|
||||||
|
const context = createCurrentRepositoryContext();
|
||||||
|
|
||||||
|
await context.drizzle
|
||||||
|
.delete(table as typeof hosts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((table as typeof hosts).syncId, syncId),
|
||||||
|
eq(table.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
entityType,
|
||||||
|
syncId,
|
||||||
|
);
|
||||||
|
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
databaseLogger.error("Failed to apply sync tombstone", err, {
|
||||||
|
operation: "sync_tombstone_apply",
|
||||||
|
entityType,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to apply deletion" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -33,6 +33,7 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||||
compactHostView: row?.compactHostView ?? null,
|
compactHostView: row?.compactHostView ?? null,
|
||||||
statusColorScheme: row?.statusColorScheme ?? null,
|
statusColorScheme: row?.statusColorScheme ?? null,
|
||||||
|
customThemes: row?.customThemes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +107,10 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
* statusColorScheme:
|
* statusColorScheme:
|
||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* nullable: true
|
||||||
|
* customThemes:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||||
*/
|
*/
|
||||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -175,6 +180,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* statusColorScheme:
|
* statusColorScheme:
|
||||||
* type: string
|
* type: string
|
||||||
|
* customThemes:
|
||||||
|
* type: string
|
||||||
|
* description: JSON-encoded array of the user's saved global custom terminal themes.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Preferences updated successfully.
|
* description: Preferences updated successfully.
|
||||||
@@ -201,6 +209,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
compactHostView,
|
compactHostView,
|
||||||
statusColorScheme,
|
statusColorScheme,
|
||||||
|
customThemes,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
reopenTabsOnLogin?: boolean;
|
reopenTabsOnLogin?: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
@@ -221,6 +230,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
compactHostView?: boolean | null;
|
compactHostView?: boolean | null;
|
||||||
statusColorScheme?: string | null;
|
statusColorScheme?: string | null;
|
||||||
|
customThemes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updates: UserPreferenceUpdate = {
|
const updates: UserPreferenceUpdate = {
|
||||||
@@ -244,12 +254,41 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
storageMode,
|
storageMode,
|
||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
statusColorScheme,
|
statusColorScheme,
|
||||||
|
customThemes,
|
||||||
})) {
|
})) {
|
||||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||||
return res.status(400).json({ error: `${key} must be a string` });
|
return res.status(400).json({ error: `${key} must be a string` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (customThemes !== undefined && customThemes !== null) {
|
||||||
|
let parsedThemes: unknown;
|
||||||
|
try {
|
||||||
|
parsedThemes = JSON.parse(customThemes);
|
||||||
|
} catch {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "customThemes must be a JSON-encoded array" });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "customThemes must be a JSON array of at most 100 themes",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const isValidTheme = (entry: unknown): boolean =>
|
||||||
|
!!entry &&
|
||||||
|
typeof entry === "object" &&
|
||||||
|
typeof (entry as { id?: unknown }).id === "string" &&
|
||||||
|
typeof (entry as { name?: unknown }).name === "string" &&
|
||||||
|
!!(entry as { colors?: unknown }).colors &&
|
||||||
|
typeof (entry as { colors?: unknown }).colors === "object";
|
||||||
|
if (!parsedThemes.every(isValidTheme)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Each custom theme must have an id, name, and colors object",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boolFields: Record<string, boolean | null | undefined> = {
|
const boolFields: Record<string, boolean | null | undefined> = {
|
||||||
commandAutocomplete,
|
commandAutocomplete,
|
||||||
commandPaletteEnabled,
|
commandPaletteEnabled,
|
||||||
@@ -294,6 +333,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||||
if (statusColorScheme !== undefined)
|
if (statusColorScheme !== undefined)
|
||||||
updates.statusColorScheme = statusColorScheme;
|
updates.statusColorScheme = statusColorScheme;
|
||||||
|
if (customThemes !== undefined) updates.customThemes = customThemes;
|
||||||
|
|
||||||
if (Object.keys(updates).length === 1) {
|
if (Object.keys(updates).length === 1) {
|
||||||
return res.status(400).json({ error: "No preferences provided" });
|
return res.status(400).json({ error: "No preferences provided" });
|
||||||
|
|||||||
@@ -519,6 +519,207 @@ export function registerUserSettingsRoutes(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/analytics-enabled:
|
||||||
|
* get:
|
||||||
|
* summary: Get analytics enabled setting
|
||||||
|
* description: Returns whether anonymous usage telemetry is enabled.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Analytics enabled status.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
*/
|
||||||
|
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"analytics_enabled",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get analytics enabled setting", err);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to get analytics enabled setting" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/analytics-enabled:
|
||||||
|
* patch:
|
||||||
|
* summary: Update analytics enabled setting (admin only)
|
||||||
|
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Setting updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update setting.
|
||||||
|
*/
|
||||||
|
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { enabled } = req.body;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||||
|
}
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
"analytics_enabled",
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_analytics_enabled",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ enabled }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update analytics enabled setting", err);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to update analytics enabled setting" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/session-sharing-enabled:
|
||||||
|
* get:
|
||||||
|
* summary: Get session sharing globally enabled setting
|
||||||
|
* description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session sharing enabled status.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
*/
|
||||||
|
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get session sharing enabled setting", err);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to get session sharing enabled setting" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/session-sharing-enabled:
|
||||||
|
* patch:
|
||||||
|
* summary: Update session sharing globally enabled setting (admin only)
|
||||||
|
* description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Setting updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update setting.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/session-sharing-enabled",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { enabled } = req.body;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||||
|
}
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_session_sharing_enabled",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ enabled }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to update session sharing enabled setting",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to update session sharing enabled setting" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/host-defaults:
|
* /users/host-defaults:
|
||||||
|
|||||||
@@ -1880,6 +1880,102 @@ router.get("/setup-required", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isLoopbackRequest(req: Request): boolean {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || "";
|
||||||
|
return (
|
||||||
|
ip === "127.0.0.1" ||
|
||||||
|
ip === "::1" ||
|
||||||
|
ip === "::ffff:127.0.0.1" ||
|
||||||
|
ip.endsWith(":127.0.0.1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/internal/auto-session:
|
||||||
|
* post:
|
||||||
|
* summary: Mint a session for the auto-provisioned local desktop user
|
||||||
|
* description: Used by the Electron desktop app to skip the login form when running standalone with a single auto-provisioned local user. Only available over loopback and only when exactly one user exists -- a real multi-user or synced install never satisfies this, so no further secret is required.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session created.
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden, or more than one user exists.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create session.
|
||||||
|
*/
|
||||||
|
router.post("/internal/auto-session", async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!isLoopbackRequest(req)) {
|
||||||
|
authLogger.warn(
|
||||||
|
"Rejected non-loopback attempt to access auto-session endpoint",
|
||||||
|
{ source: req.ip },
|
||||||
|
);
|
||||||
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const allUsers = await userRepository.listAll();
|
||||||
|
if (allUsers.length !== 1) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Auto-session is only available for a single local user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRecord = allUsers[0];
|
||||||
|
|
||||||
|
// If the caller already holds a still-valid session for this same
|
||||||
|
// user (e.g. a duplicate call racing the first one, such as React
|
||||||
|
// StrictMode's double-invoke of effects in dev), reuse it instead of
|
||||||
|
// minting a fresh one. Minting unconditionally here would set a new
|
||||||
|
// `jwt` cookie on every call; since the auth middleware prefers the
|
||||||
|
// cookie over the Authorization header, a second mint silently
|
||||||
|
// invalidates whatever token the app already started using.
|
||||||
|
const existingToken =
|
||||||
|
(req as Request & { cookies?: Record<string, string> }).cookies?.jwt ||
|
||||||
|
(req.headers["authorization"]?.startsWith("Bearer ")
|
||||||
|
? req.headers["authorization"].slice("Bearer ".length)
|
||||||
|
: undefined);
|
||||||
|
if (existingToken) {
|
||||||
|
const existingPayload = await authManager.verifyJWTToken(existingToken);
|
||||||
|
if (existingPayload?.userId === userRecord.id) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
is_admin: !!userRecord.isAdmin,
|
||||||
|
username: userRecord.username,
|
||||||
|
token: existingToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
|
deviceType: "desktop",
|
||||||
|
deviceInfo: "Termix Desktop (local)",
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
is_admin: !!userRecord.isAdmin,
|
||||||
|
username: userRecord.username,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
|
||||||
|
return res
|
||||||
|
.cookie(
|
||||||
|
"jwt",
|
||||||
|
token,
|
||||||
|
authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
|
.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to create auto-session", err);
|
||||||
|
res.status(500).json({ error: "Failed to create auto-session" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/count:
|
* /users/count:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
|
|||||||
import {
|
import {
|
||||||
createCurrentVaultProfileRepository,
|
createCurrentVaultProfileRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
|
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
@@ -421,7 +422,14 @@ router.delete(
|
|||||||
.status(403)
|
.status(403)
|
||||||
.json({ error: "Only the owner can delete this profile" });
|
.json({ error: "Only the owner can delete this profile" });
|
||||||
}
|
}
|
||||||
await repository.deleteById(id);
|
const deleted = await repository.deleteById(id);
|
||||||
|
if (deleted?.syncId) {
|
||||||
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
userId,
|
||||||
|
"vaultProfiles",
|
||||||
|
deleted.syncId,
|
||||||
|
);
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to delete vault profile", err);
|
authLogger.error("Failed to delete vault profile", err);
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
|
|||||||
|
|
||||||
if (userProvidedPassword) {
|
if (userProvidedPassword) {
|
||||||
resolvedCredentials.password = userProvidedPassword;
|
resolvedCredentials.password = userProvidedPassword;
|
||||||
|
resolvedCredentials.authType = "password";
|
||||||
}
|
}
|
||||||
if (userProvidedSshKey) {
|
if (userProvidedSshKey) {
|
||||||
resolvedCredentials.sshKey = userProvidedSshKey;
|
resolvedCredentials.sshKey = userProvidedSshKey;
|
||||||
|
|||||||
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
|
|||||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||||
|
|
||||||
type GuacamoleClientConnection = {
|
type GuacamoleClientConnection = {
|
||||||
|
guacamoleConnectionId?: string;
|
||||||
connectionSettings?: {
|
connectionSettings?: {
|
||||||
connection?: { type?: string };
|
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||||
recording?: GuacamoleRecordingMetadata;
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
termixMeta?: {
|
||||||
|
termixConnectId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GuacSessionInfo {
|
||||||
|
guacamoleConnectionId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
openedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||||
|
// primary connection's guacd handshake completes.
|
||||||
|
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||||
|
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||||
|
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||||
|
const pendingConnectResolvers = new Map<
|
||||||
|
string,
|
||||||
|
(info: GuacSessionInfo | null) => void
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function waitForGuacdOpen(
|
||||||
|
termixConnectId: string,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<GuacSessionInfo | null> {
|
||||||
|
const existing = guacSessionByConnectId.get(termixConnectId);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (info: GuacSessionInfo | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
pendingConnectResolvers.delete(termixConnectId);
|
||||||
|
resolve(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingConnectResolvers.set(termixConnectId, finish);
|
||||||
|
setTimeout(() => finish(null), timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGuacSessionInfo(
|
||||||
|
guacamoleConnectionId: string,
|
||||||
|
): GuacSessionInfo | null {
|
||||||
|
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function persistGuacamoleRecording(
|
async function persistGuacamoleRecording(
|
||||||
clientConnection: GuacamoleClientConnection,
|
clientConnection: GuacamoleClientConnection,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -118,7 +170,6 @@ const clientOptions = {
|
|||||||
vnc: {
|
vnc: {
|
||||||
"swap-red-blue": false,
|
"swap-red-blue": false,
|
||||||
cursor: "remote",
|
cursor: "remote",
|
||||||
security: "any",
|
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
},
|
},
|
||||||
@@ -149,6 +200,25 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
operation: "guac_connection_open",
|
operation: "guac_connection_open",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||||
|
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||||
|
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||||
|
|
||||||
|
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||||
|
const info: GuacSessionInfo = {
|
||||||
|
guacamoleConnectionId,
|
||||||
|
hostId: termixMeta.hostId,
|
||||||
|
ownerUserId: termixMeta.ownerUserId,
|
||||||
|
protocol: termixMeta.protocol,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
};
|
||||||
|
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||||
|
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||||
|
|
||||||
|
const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
|
||||||
|
if (resolver) resolver(info);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||||
@@ -156,6 +226,15 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
operation: "guac_connection_close",
|
operation: "guac_connection_close",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||||
|
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||||
|
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||||
|
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||||
|
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||||
|
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||||
operation: "guac_recording_persist_error",
|
operation: "guac_recording_persist_error",
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import { GuacamoleTokenService } from "./token-service.js";
|
|||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { Client } from "ssh2";
|
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
} from "../../database/repositories/factory.js";
|
} from "../../database/repositories/factory.js";
|
||||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||||
|
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||||
|
import type { SOCKS5Config } from "../../utils/socks5-helper.js";
|
||||||
|
import { waitForGuacdOpen } from "./guacamole-server.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
@@ -165,6 +167,12 @@ router.post("/token", async (req, res) => {
|
|||||||
* type: string
|
* type: string
|
||||||
* enum: [rdp, vnc, telnet]
|
* enum: [rdp, vnc, telnet]
|
||||||
* description: Override the host's default connection type
|
* description: Override the host's default connection type
|
||||||
|
* promptedUsername:
|
||||||
|
* type: string
|
||||||
|
* description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||||
|
* promptedPassword:
|
||||||
|
* type: string
|
||||||
|
* description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Connection token generated successfully
|
* description: Connection token generated successfully
|
||||||
@@ -176,6 +184,10 @@ router.post("/token", async (req, res) => {
|
|||||||
* token:
|
* token:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Encrypted connection token
|
* description: Encrypted connection token
|
||||||
|
* guacamoleConnectionId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid request or unsupported connection type
|
* description: Invalid request or unsupported connection type
|
||||||
* 403:
|
* 403:
|
||||||
@@ -421,12 +433,22 @@ router.post(
|
|||||||
let username: string;
|
let username: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
|
|
||||||
|
const rdpAuthTypeForConnect = isSharedConnection
|
||||||
|
? null
|
||||||
|
: (host.rdpAuthType as string) ||
|
||||||
|
(host.rdpCredentialId ? "credential" : "direct");
|
||||||
|
|
||||||
switch (connectionType) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
username =
|
if (rdpAuthTypeForConnect === "none") {
|
||||||
(host.rdpUser as string) || (host.username as string) || "";
|
username = String(req.body?.promptedUsername || "");
|
||||||
password =
|
password = String(req.body?.promptedPassword || "");
|
||||||
(host.rdpPassword as string) || (host.password as string) || "";
|
} else {
|
||||||
|
username =
|
||||||
|
(host.rdpUser as string) || (host.username as string) || "";
|
||||||
|
password =
|
||||||
|
(host.rdpPassword as string) || (host.password as string) || "";
|
||||||
|
}
|
||||||
port = (host.rdpPort as number) || port || 3389;
|
port = (host.rdpPort as number) || port || 3389;
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
@@ -463,65 +485,91 @@ router.post(
|
|||||||
|
|
||||||
if (jumpHosts.length > 0) {
|
if (jumpHosts.length > 0) {
|
||||||
try {
|
try {
|
||||||
const { resolveHostById } = await import("../host-resolver.js");
|
let socks5ProxyChain: ProxyNode[] = [];
|
||||||
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
|
if (hostRecord.socks5ProxyChain) {
|
||||||
if (jumpHost) {
|
try {
|
||||||
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
socks5ProxyChain =
|
||||||
const sshClient = new Client();
|
typeof hostRecord.socks5ProxyChain === "string"
|
||||||
sshClient.on("ready", () => {
|
? JSON.parse(hostRecord.socks5ProxyChain as string)
|
||||||
const server = net.createServer((sock) => {
|
: (hostRecord.socks5ProxyChain as ProxyNode[]);
|
||||||
sshClient.forwardOut(
|
} catch {
|
||||||
"127.0.0.1",
|
socks5ProxyChain = [];
|
||||||
0,
|
}
|
||||||
hostname,
|
}
|
||||||
port,
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
sock.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sock.pipe(stream).pipe(sock);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
|
||||||
const addr = server.address() as net.AddressInfo;
|
|
||||||
// Auto-cleanup after 1 hour
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
server.close();
|
|
||||||
sshClient.end();
|
|
||||||
},
|
|
||||||
60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
resolve(addr.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
sshClient.on("error", reject);
|
|
||||||
|
|
||||||
const connectOpts: Record<string, unknown> = {
|
const proxyConfig: SOCKS5Config | null =
|
||||||
host: jumpHost.ip,
|
hostRecord.useSocks5 &&
|
||||||
port: jumpHost.port || 22,
|
(hostRecord.socks5Host || socks5ProxyChain.length > 0)
|
||||||
username: jumpHost.username,
|
? {
|
||||||
readyTimeout: 30000,
|
useSocks5: hostRecord.useSocks5 as boolean,
|
||||||
};
|
socks5Host: hostRecord.socks5Host as string | undefined,
|
||||||
if (jumpHost.key) {
|
socks5Port: hostRecord.socks5Port as number | undefined,
|
||||||
connectOpts.privateKey = jumpHost.key;
|
socks5Username: hostRecord.socks5Username as
|
||||||
if (jumpHost.keyPassword)
|
| string
|
||||||
connectOpts.passphrase = jumpHost.keyPassword;
|
| undefined,
|
||||||
} else if (jumpHost.password) {
|
socks5Password: hostRecord.socks5Password as
|
||||||
connectOpts.password = jumpHost.password;
|
| string
|
||||||
}
|
| undefined,
|
||||||
sshClient.connect(connectOpts);
|
socks5ProxyChain,
|
||||||
});
|
}
|
||||||
hostname = "127.0.0.1";
|
: null;
|
||||||
port = tunnelPort;
|
|
||||||
guacLogger.info("SSH tunnel established for guacamole", {
|
const jumpClient = await createJumpHostChain(
|
||||||
operation: "guac_ssh_tunnel",
|
jumpHosts,
|
||||||
hostId,
|
userId,
|
||||||
tunnelPort,
|
proxyConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jumpClient) {
|
||||||
|
guacLogger.error(
|
||||||
|
"Failed to establish jump host chain for guacamole",
|
||||||
|
undefined,
|
||||||
|
{ operation: "guac_ssh_tunnel_error", hostId },
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to establish SSH tunnel to remote host",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetHostname = hostname;
|
||||||
|
const targetPort = port;
|
||||||
|
const tunnelPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer((sock) => {
|
||||||
|
jumpClient.forwardOut(
|
||||||
|
"127.0.0.1",
|
||||||
|
0,
|
||||||
|
targetHostname,
|
||||||
|
targetPort,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
sock.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sock.pipe(stream).pipe(sock);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const addr = server.address() as net.AddressInfo;
|
||||||
|
// Auto-cleanup after 1 hour
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
server.close();
|
||||||
|
jumpClient.end();
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
resolve(addr.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
hostname = "127.0.0.1";
|
||||||
|
port = tunnelPort;
|
||||||
|
guacLogger.info("SSH tunnel established for guacamole", {
|
||||||
|
operation: "guac_ssh_tunnel",
|
||||||
|
hostId,
|
||||||
|
tunnelPort,
|
||||||
|
});
|
||||||
} catch (tunnelError) {
|
} catch (tunnelError) {
|
||||||
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
|
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
|
||||||
operation: "guac_ssh_tunnel_error",
|
operation: "guac_ssh_tunnel_error",
|
||||||
@@ -541,7 +589,8 @@ router.post(
|
|||||||
? { guacdPort: perConnectionGuacdPort }
|
? { guacdPort: perConnectionGuacdPort }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
const recordingEnabled = host.enableSessionLogging !== false;
|
const recordingEnabled =
|
||||||
|
connectionType !== "vnc" && host.enableSessionLogging !== false;
|
||||||
const recordingName = `${crypto.randomUUID()}.guac`;
|
const recordingName = `${crypto.randomUUID()}.guac`;
|
||||||
const recordingPath =
|
const recordingPath =
|
||||||
process.env.GUACD_RECORDING_PATH ||
|
process.env.GUACD_RECORDING_PATH ||
|
||||||
@@ -564,6 +613,14 @@ router.post(
|
|||||||
guacConfig["recording-include-keys"] = true;
|
guacConfig["recording-include-keys"] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const termixConnectId = crypto.randomUUID();
|
||||||
|
const termixMeta = {
|
||||||
|
termixConnectId,
|
||||||
|
hostId,
|
||||||
|
ownerUserId: userId,
|
||||||
|
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||||
|
};
|
||||||
|
|
||||||
switch (connectionType) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||||
@@ -591,6 +648,7 @@ router.post(
|
|||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
@@ -600,11 +658,11 @@ router.post(
|
|||||||
password,
|
password,
|
||||||
{
|
{
|
||||||
port,
|
port,
|
||||||
security: "any",
|
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
@@ -618,13 +676,19 @@ router.post(
|
|||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({ error: "Invalid connection type" });
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ token });
|
const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||||
operation: "guac_host_token_error",
|
operation: "guac_host_token_error",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import crypto from "crypto";
|
|||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
export interface GuacamoleConnectionSettings {
|
export interface GuacamoleConnectionSettings {
|
||||||
type: "rdp" | "vnc" | "telnet";
|
type?: "rdp" | "vnc" | "telnet";
|
||||||
|
join?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
settings: {
|
settings: {
|
||||||
hostname: string;
|
hostname?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TermixGuacMeta {
|
||||||
|
termixConnectId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: "rdp" | "vnc" | "telnet";
|
||||||
|
}
|
||||||
|
|
||||||
export interface GuacamoleToken {
|
export interface GuacamoleToken {
|
||||||
connection: GuacamoleConnectionSettings;
|
connection: GuacamoleConnectionSettings;
|
||||||
recording?: GuacamoleRecordingMetadata;
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
termixMeta?: TermixGuacMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuacamoleRecordingMetadata {
|
export interface GuacamoleRecordingMetadata {
|
||||||
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
|
};
|
||||||
|
return this.encryptToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// join tokens never carry recording params - only the primary connection's
|
||||||
|
// token should write recording-path/recording-name to guacd.
|
||||||
|
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||||
|
const token: GuacamoleToken = {
|
||||||
|
connection: {
|
||||||
|
join: guacamoleConnectionId,
|
||||||
|
readOnly,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,35 +122,61 @@ export async function resolveHostById(
|
|||||||
repository,
|
repository,
|
||||||
);
|
);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
} else if (host.credentialId) {
|
} else {
|
||||||
try {
|
let effectiveCredentialId = host.credentialId as number | null | undefined;
|
||||||
const cred = (await repository.findCredentialByIdForUser(
|
if (
|
||||||
host.credentialId as number,
|
!effectiveCredentialId &&
|
||||||
ownerId,
|
host.authType === "credential" &&
|
||||||
)) as Record<string, unknown> | null;
|
host.folder
|
||||||
|
) {
|
||||||
if (cred) {
|
try {
|
||||||
host.password = pickResolvedPassword(host.password, cred.password);
|
effectiveCredentialId = await repository.findFolderCredentialId(
|
||||||
// Prefer the normalised private key; fall back to raw key field
|
ownerId,
|
||||||
host.key = (cred.privateKey || cred.key) as string | null;
|
host.folder as string,
|
||||||
host.keyPassword = cred.keyPassword;
|
|
||||||
host.keyType = cred.keyType;
|
|
||||||
// CA-signed certificate for cert-based auth
|
|
||||||
(host as Record<string, unknown>).certPublicKey =
|
|
||||||
cred.certPublicKey || null;
|
|
||||||
host.username = pickResolvedUsername(
|
|
||||||
host.username,
|
|
||||||
cred.username,
|
|
||||||
host.overrideCredentialUsername,
|
|
||||||
);
|
);
|
||||||
host.authType = host.key ? "key" : host.password ? "password" : "none";
|
} catch (e) {
|
||||||
|
sshLogger.warn("Failed to resolve folder credential for host", {
|
||||||
|
operation: "host_resolver_folder_credential",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveCredentialId) {
|
||||||
|
try {
|
||||||
|
const cred = (await repository.findCredentialByIdForUser(
|
||||||
|
effectiveCredentialId,
|
||||||
|
ownerId,
|
||||||
|
)) as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
if (cred) {
|
||||||
|
host.password = pickResolvedPassword(host.password, cred.password);
|
||||||
|
// Prefer the normalised private key; fall back to raw key field
|
||||||
|
host.key = (cred.privateKey || cred.key) as string | null;
|
||||||
|
host.keyPassword = cred.keyPassword;
|
||||||
|
host.keyType = cred.keyType;
|
||||||
|
// CA-signed certificate for cert-based auth
|
||||||
|
(host as Record<string, unknown>).certPublicKey =
|
||||||
|
cred.certPublicKey || null;
|
||||||
|
host.username = pickResolvedUsername(
|
||||||
|
host.username,
|
||||||
|
cred.username,
|
||||||
|
host.overrideCredentialUsername,
|
||||||
|
);
|
||||||
|
host.authType = host.key
|
||||||
|
? "key"
|
||||||
|
: host.password
|
||||||
|
? "password"
|
||||||
|
: "none";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sshLogger.warn("Failed to resolve credential for host", {
|
||||||
|
operation: "host_resolver_credential",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
sshLogger.warn("Failed to resolve credential for host", {
|
|
||||||
operation: "host_resolver_credential",
|
|
||||||
hostId,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
|
|||||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||||
|
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||||
import {
|
import {
|
||||||
createSocks5Connection,
|
createSocks5Connection,
|
||||||
type SOCKS5Config,
|
type SOCKS5Config,
|
||||||
@@ -146,6 +147,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
|
|||||||
"processes",
|
"processes",
|
||||||
"ports",
|
"ports",
|
||||||
"firewall",
|
"firewall",
|
||||||
|
"temperature",
|
||||||
],
|
],
|
||||||
statusCheckEnabled: true,
|
statusCheckEnabled: true,
|
||||||
statusCheckInterval: 60,
|
statusCheckInterval: 60,
|
||||||
@@ -1582,6 +1584,21 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
// expected
|
// expected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let temperature: {
|
||||||
|
source: "sysfs" | "sensors" | "none";
|
||||||
|
highestCelsius: number | null;
|
||||||
|
sensors: Array<{ label: string; celsius: number }>;
|
||||||
|
} = {
|
||||||
|
source: "none",
|
||||||
|
highestCelsius: null,
|
||||||
|
sensors: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
temperature = await collectTemperatureMetrics(client);
|
||||||
|
} catch {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
cpu,
|
cpu,
|
||||||
memory,
|
memory,
|
||||||
@@ -1593,6 +1610,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
login_stats,
|
login_stats,
|
||||||
ports,
|
ports,
|
||||||
firewall,
|
firewall,
|
||||||
|
temperature,
|
||||||
};
|
};
|
||||||
|
|
||||||
metricsCache.set(host.id, result);
|
metricsCache.set(host.id, result);
|
||||||
|
|||||||
@@ -1,6 +1,67 @@
|
|||||||
import type { Client } from "ssh2";
|
import type { Client } from "ssh2";
|
||||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||||
|
|
||||||
|
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
|
||||||
|
|
||||||
|
export interface DfRow {
|
||||||
|
filesystem: string;
|
||||||
|
mount: string;
|
||||||
|
parts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDfLines(output: string): DfRow[] {
|
||||||
|
return output
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
return { filesystem: parts[0] || "", mount: parts[5] || "", parts };
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(row) => row.parts.length >= 6 && !PSEUDO_FS_RE.test(row.filesystem),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finds the index of the most-utilized real filesystem in a `df -B1`-style
|
||||||
|
// row set (parts[1] = total bytes, parts[2] = used bytes), so a nearly-full
|
||||||
|
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
|
||||||
|
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||||
|
index: number;
|
||||||
|
usedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
} {
|
||||||
|
let worstIndex = -1;
|
||||||
|
let worstUsedBytes = -1;
|
||||||
|
let worstTotalBytes = 0;
|
||||||
|
|
||||||
|
bytesRows.forEach((row, index) => {
|
||||||
|
const totalBytes = Number(row.parts[1]);
|
||||||
|
const usedBytes = Number(row.parts[2]);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(totalBytes) ||
|
||||||
|
!Number.isFinite(usedBytes) ||
|
||||||
|
totalBytes <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usedRatio = usedBytes / totalBytes;
|
||||||
|
const worstRatio =
|
||||||
|
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||||
|
if (usedRatio > worstRatio) {
|
||||||
|
worstIndex = index;
|
||||||
|
worstUsedBytes = usedBytes;
|
||||||
|
worstTotalBytes = totalBytes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: worstIndex,
|
||||||
|
usedBytes: worstUsedBytes,
|
||||||
|
totalBytes: worstTotalBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectDiskMetrics(client: Client): Promise<{
|
export async function collectDiskMetrics(client: Client): Promise<{
|
||||||
percent: number | null;
|
percent: number | null;
|
||||||
usedHuman: string | null;
|
usedHuman: string | null;
|
||||||
@@ -14,41 +75,28 @@ export async function collectDiskMetrics(client: Client): Promise<{
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||||
execCommand(client, "df -h -P / | tail -n +2"),
|
execCommand(client, "df -h -P | tail -n +2"),
|
||||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
execCommand(client, "df -B1 -P | tail -n +2"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const humanLine =
|
const humanRows = parseDfLines(diskOutHuman.stdout);
|
||||||
diskOutHuman.stdout
|
const bytesRows = parseDfLines(diskOutBytes.stdout);
|
||||||
.split("\n")
|
const worst = findWorstMountIndex(bytesRows);
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)[0] || "";
|
|
||||||
const bytesLine =
|
|
||||||
diskOutBytes.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)[0] || "";
|
|
||||||
|
|
||||||
const humanParts = humanLine.split(/\s+/);
|
if (worst.totalBytes > 0) {
|
||||||
const bytesParts = bytesLine.split(/\s+/);
|
diskPercent = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (worst.usedBytes / worst.totalBytes) * 100),
|
||||||
|
);
|
||||||
|
|
||||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
const humanRow =
|
||||||
totalHuman = humanParts[1] || null;
|
humanRows.length === bytesRows.length
|
||||||
usedHuman = humanParts[2] || null;
|
? humanRows[worst.index]
|
||||||
availableHuman = humanParts[3] || null;
|
: humanRows.find((row) => row.mount === bytesRows[worst.index].mount);
|
||||||
|
if (humanRow) {
|
||||||
const totalBytes = Number(bytesParts[1]);
|
totalHuman = humanRow.parts[1] || null;
|
||||||
const usedBytes = Number(bytesParts[2]);
|
usedHuman = humanRow.parts[2] || null;
|
||||||
|
availableHuman = humanRow.parts[3] || null;
|
||||||
if (
|
|
||||||
Number.isFinite(totalBytes) &&
|
|
||||||
Number.isFinite(usedBytes) &&
|
|
||||||
totalBytes > 0
|
|
||||||
) {
|
|
||||||
diskPercent = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import express from "express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
import { sessionManager } from "../terminal/session-manager.js";
|
||||||
|
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
|
||||||
|
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||||
|
import {
|
||||||
|
createCurrentSessionShareRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
|
||||||
|
const DEFAULT_EXPIRY_HOURS = 24;
|
||||||
|
const MAX_EXPIRY_HOURS = 24 * 30;
|
||||||
|
|
||||||
|
type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
type PermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
|
interface ResolveRateEntry {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
}
|
||||||
|
const resolveAttempts = new Map<string, ResolveRateEntry>();
|
||||||
|
const RESOLVE_WINDOW_MS = 60 * 1000;
|
||||||
|
const RESOLVE_MAX_ATTEMPTS = 30;
|
||||||
|
|
||||||
|
function isResolveRateLimited(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = resolveAttempts.get(ip);
|
||||||
|
if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
|
||||||
|
resolveAttempts.set(ip, { count: 1, windowStart: now });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.count += 1;
|
||||||
|
return entry.count > RESOLVE_MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, entry] of resolveAttempts.entries()) {
|
||||||
|
if (now - entry.windowStart > RESOLVE_WINDOW_MS)
|
||||||
|
resolveAttempts.delete(ip);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function isSharingEnabledForHost(hostId: number): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
hostOwnerId: string | null;
|
||||||
|
}> {
|
||||||
|
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
|
||||||
|
|
||||||
|
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||||
|
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||||
|
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
|
||||||
|
|
||||||
|
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
|
||||||
|
if (!host) return { enabled: false, hostOwnerId: null };
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: host.allowSessionSharing !== false,
|
||||||
|
hostOwnerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||||
|
const hours = Math.min(
|
||||||
|
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||||
|
MAX_EXPIRY_HOURS,
|
||||||
|
);
|
||||||
|
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLiveSessionOwnedBy(
|
||||||
|
protocol: Protocol,
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
): boolean {
|
||||||
|
if (protocol === "ssh") {
|
||||||
|
const session = sessionManager.getSession(sessionId);
|
||||||
|
return !!session && session.isConnected && session.userId === userId;
|
||||||
|
}
|
||||||
|
const info = getGuacSessionInfo(sessionId);
|
||||||
|
return !!info && info.ownerUserId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLiveSession(protocol: Protocol, sessionId: string): boolean {
|
||||||
|
if (protocol === "ssh") {
|
||||||
|
const session = sessionManager.getSession(sessionId);
|
||||||
|
return !!session && session.isConnected;
|
||||||
|
}
|
||||||
|
return !!getGuacSessionInfo(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /session-sharing/create:
|
||||||
|
* post:
|
||||||
|
* summary: Create a session share (link or targeted user)
|
||||||
|
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
||||||
|
* tags:
|
||||||
|
* - Session Sharing
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - hostId
|
||||||
|
* - sessionId
|
||||||
|
* - protocol
|
||||||
|
* - shareType
|
||||||
|
* - permissionLevel
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* tabInstanceId:
|
||||||
|
* type: string
|
||||||
|
* protocol:
|
||||||
|
* type: string
|
||||||
|
* enum: [ssh, rdp, vnc, telnet]
|
||||||
|
* shareType:
|
||||||
|
* type: string
|
||||||
|
* enum: [link, user]
|
||||||
|
* targetUserId:
|
||||||
|
* type: string
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* enum: [read-only, read-write]
|
||||||
|
* expiryHours:
|
||||||
|
* type: number
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Share created
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request
|
||||||
|
* 403:
|
||||||
|
* description: Sharing disabled, or caller does not own the session
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const {
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
tabInstanceId,
|
||||||
|
protocol,
|
||||||
|
shareType,
|
||||||
|
targetUserId,
|
||||||
|
permissionLevel,
|
||||||
|
expiryHours,
|
||||||
|
} = req.body ?? {};
|
||||||
|
|
||||||
|
if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
|
||||||
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
|
}
|
||||||
|
if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
|
||||||
|
return res.status(400).json({ error: "Invalid protocol" });
|
||||||
|
}
|
||||||
|
if (!["link", "user"].includes(shareType)) {
|
||||||
|
return res.status(400).json({ error: "Invalid shareType" });
|
||||||
|
}
|
||||||
|
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
||||||
|
return res.status(400).json({ error: "Invalid permissionLevel" });
|
||||||
|
}
|
||||||
|
if (shareType === "user" && !targetUserId) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "targetUserId is required for user shares" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericHostId = Number(hostId);
|
||||||
|
|
||||||
|
const { enabled: sharingEnabled } =
|
||||||
|
await isSharingEnabledForHost(numericHostId);
|
||||||
|
if (!sharingEnabled) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Session sharing is disabled for this host" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "You do not own this live session" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareType === "user") {
|
||||||
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
|
targetUserId,
|
||||||
|
numericHostId,
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
if (!accessInfo.hasAccess) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Target user does not have access to this host",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareId = crypto.randomUUID();
|
||||||
|
const linkToken =
|
||||||
|
shareType === "link"
|
||||||
|
? crypto.randomBytes(24).toString("base64url")
|
||||||
|
: null;
|
||||||
|
const expiresAt = computeExpiresAt(expiryHours);
|
||||||
|
|
||||||
|
const created = await createCurrentSessionShareRepository().create({
|
||||||
|
id: shareId,
|
||||||
|
hostId: numericHostId,
|
||||||
|
ownerUserId: userId,
|
||||||
|
protocol,
|
||||||
|
sessionId: String(sessionId),
|
||||||
|
tabInstanceId: tabInstanceId ?? null,
|
||||||
|
shareType,
|
||||||
|
targetUserId: shareType === "user" ? targetUserId : null,
|
||||||
|
linkToken,
|
||||||
|
permissionLevel,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
shareId: created.id,
|
||||||
|
linkToken: created.linkToken,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to create session share", error, {
|
||||||
|
operation: "session_share_create_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to create session share" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /session-sharing/host/{hostId}/active:
|
||||||
|
* get:
|
||||||
|
* summary: List active session shares for a host
|
||||||
|
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
||||||
|
* tags:
|
||||||
|
* - Session Sharing
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of active shares
|
||||||
|
* 400:
|
||||||
|
* description: Invalid host id
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/host/:hostId/active",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||||
|
if (!hostId || Number.isNaN(hostId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares =
|
||||||
|
await createCurrentSessionShareRepository().findActiveSharesForHost(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ shares });
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to list session shares", error, {
|
||||||
|
operation: "session_share_list_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to list session shares" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /session-sharing/{shareId}:
|
||||||
|
* delete:
|
||||||
|
* summary: Revoke a session share
|
||||||
|
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
|
||||||
|
* tags:
|
||||||
|
* - Session Sharing
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: shareId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Share revoked
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized to revoke this share
|
||||||
|
* 404:
|
||||||
|
* description: Share not found
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/:shareId",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const shareId = String(req.params.shareId);
|
||||||
|
|
||||||
|
const repository = createCurrentSessionShareRepository();
|
||||||
|
const share = await repository.findById(shareId);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(404).json({ error: "Share not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let revoked = await repository.revoke(shareId, userId);
|
||||||
|
if (!revoked) {
|
||||||
|
if (await permissionManager.isAdmin(userId)) {
|
||||||
|
revoked = await repository.revokeAsAdmin(shareId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!revoked) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Not authorized to revoke this share" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort kick of live participants. SSH sessions support ending
|
||||||
|
// just the guests via ownerEndSession; guac joins aren't force-kickable
|
||||||
|
// from a REST handler (guacamole-lite exposes no kick API), so a revoked
|
||||||
|
// guac link only blocks *future* resolves until the guest's own socket ends.
|
||||||
|
if (share.protocol === "ssh") {
|
||||||
|
try {
|
||||||
|
sessionManager.ownerEndSession(
|
||||||
|
share.sessionId,
|
||||||
|
"Session share revoked by owner",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to revoke session share", error, {
|
||||||
|
operation: "session_share_revoke_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to revoke session share" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /session-sharing/resolve/{linkToken}:
|
||||||
|
* get:
|
||||||
|
* summary: Resolve a guest share link
|
||||||
|
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
||||||
|
* tags:
|
||||||
|
* - Session Sharing
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: linkToken
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Resolved share connection info
|
||||||
|
* 404:
|
||||||
|
* description: Link not found, expired, revoked, or sharing disabled
|
||||||
|
* 429:
|
||||||
|
* description: Too many requests
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
if (isResolveRateLimited(ip)) {
|
||||||
|
return res.status(429).json({ error: "Too many requests" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkToken = String(req.params.linkToken);
|
||||||
|
const repository = createCurrentSessionShareRepository();
|
||||||
|
const share = await repository.findByLinkToken(linkToken);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(404).json({ error: "Link not found or expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { enabled: sharingEnabled } = await isSharingEnabledForHost(
|
||||||
|
share.hostId,
|
||||||
|
);
|
||||||
|
if (!sharingEnabled) {
|
||||||
|
return res.status(404).json({ error: "Link not found or expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = share.protocol as Protocol;
|
||||||
|
if (!isLiveSession(protocol, share.sessionId)) {
|
||||||
|
return res.status(404).json({ error: "Session is no longer active" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field-by-field by design - never spread a host row into this response.
|
||||||
|
// Anonymous guests must never see hostname/IP/username/hostId (decision #5).
|
||||||
|
const response: {
|
||||||
|
protocol: Protocol;
|
||||||
|
permissionLevel: PermissionLevel;
|
||||||
|
wsPath: string;
|
||||||
|
connectParams?: Record<string, string>;
|
||||||
|
} = {
|
||||||
|
protocol,
|
||||||
|
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||||
|
wsPath:
|
||||||
|
protocol === "ssh"
|
||||||
|
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
|
||||||
|
: "/guacamole/websocket/",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol !== "ssh") {
|
||||||
|
const joinToken = tokenService.createJoinToken(
|
||||||
|
share.sessionId,
|
||||||
|
share.permissionLevel === "read-only",
|
||||||
|
);
|
||||||
|
response.connectParams = { token: joinToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await repository.touchShareUsage(share.id);
|
||||||
|
await repository.recordParticipantJoin(share.id, null, "Guest");
|
||||||
|
} catch {
|
||||||
|
// best-effort, never fail the resolve response over audit bookkeeping
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to resolve session share link", error, {
|
||||||
|
operation: "session_share_resolve_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to resolve share link" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /session-sharing/{shareId}/end:
|
||||||
|
* post:
|
||||||
|
* summary: End a shared session for all participants
|
||||||
|
* description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
|
||||||
|
* tags:
|
||||||
|
* - Session Sharing
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: shareId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session ended
|
||||||
|
* 403:
|
||||||
|
* description: Not the owner of this share
|
||||||
|
* 404:
|
||||||
|
* description: Share not found
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:shareId/end",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const shareId = String(req.params.shareId);
|
||||||
|
|
||||||
|
const repository = createCurrentSessionShareRepository();
|
||||||
|
const share = await repository.findById(shareId);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(404).json({ error: "Share not found" });
|
||||||
|
}
|
||||||
|
if (share.ownerUserId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Not the owner of this share" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.protocol === "ssh") {
|
||||||
|
sessionManager.ownerEndSession(
|
||||||
|
share.sessionId,
|
||||||
|
"Session ended by owner",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Guac protocols: no kick API available from a REST handler in v1 - see
|
||||||
|
// DELETE /:shareId for the same limitation.
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to end shared session", error, {
|
||||||
|
operation: "session_share_end_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to end shared session" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js";
|
|||||||
import type { ProxyNode } from "../../../types/index.js";
|
import type { ProxyNode } from "../../../types/index.js";
|
||||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||||
import { sessionManager } from "./session-manager.js";
|
import {
|
||||||
|
sessionManager,
|
||||||
|
isMessageAllowedForParticipant,
|
||||||
|
} from "./session-manager.js";
|
||||||
|
import {
|
||||||
|
createCurrentSessionShareRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
detectTmux,
|
detectTmux,
|
||||||
attachOrCreateTmuxSession,
|
attachOrCreateTmuxSession,
|
||||||
@@ -105,10 +112,159 @@ const wss = new WebSocketServer({
|
|||||||
port: 30002,
|
port: 30002,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth path for anonymous share-link guests (?shareToken=<linkToken>).
|
||||||
|
* Never touches DataCrypto/user credentials - guests join an already-live
|
||||||
|
* stream and never decrypt stored secrets.
|
||||||
|
*/
|
||||||
|
async function handleShareTokenConnection(
|
||||||
|
ws: WebSocket,
|
||||||
|
req: import("http").IncomingMessage,
|
||||||
|
shareToken: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const shareRepo = createCurrentSessionShareRepository();
|
||||||
|
const share = await shareRepo.findByLinkToken(shareToken);
|
||||||
|
if (!share) {
|
||||||
|
ws.close(1008, "Invalid or expired share link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (share.protocol !== "ssh") {
|
||||||
|
ws.close(1008, "Unsupported share protocol");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globallyEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!globallyEnabled) {
|
||||||
|
ws.close(1008, "Session sharing is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||||
|
share.hostId,
|
||||||
|
share.ownerUserId,
|
||||||
|
);
|
||||||
|
if (!host || host.allowSessionSharing === false) {
|
||||||
|
ws.close(1008, "Session sharing is disabled for this host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(share.sessionId);
|
||||||
|
if (!session || !session.isConnected) {
|
||||||
|
ws.close(1008, "Session has ended");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionLevel = share.permissionLevel as "read-write" | "read-only";
|
||||||
|
const joined = sessionManager.joinAsParticipant(share.sessionId, ws, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel,
|
||||||
|
guestLabel: "Guest",
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
if (!joined) {
|
||||||
|
ws.close(1008, "Session is no longer active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||||
|
shareRepo.recordParticipantJoin(share.id, null, "Guest").catch(() => {});
|
||||||
|
|
||||||
|
const buffered = sessionManager.getBuffer(joined);
|
||||||
|
if (buffered) {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "sessionAttached", sessionId: share.sessionId }),
|
||||||
|
);
|
||||||
|
ws.send(JSON.stringify({ type: "connected", message: "Joined session" }));
|
||||||
|
|
||||||
|
const currentSessionId: string = share.sessionId;
|
||||||
|
|
||||||
|
let wsAlive = true;
|
||||||
|
ws.on("pong", () => {
|
||||||
|
wsAlive = true;
|
||||||
|
});
|
||||||
|
const wsPingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
if (!wsAlive) {
|
||||||
|
ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
} else {
|
||||||
|
clearInterval(wsPingInterval);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
clearInterval(wsPingInterval);
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
sshLogger.info("Guest left shared terminal session", {
|
||||||
|
operation: "terminal_guest_disconnect",
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (msg: RawData) => {
|
||||||
|
let parsed: WebSocketMessage;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
const liveSession = sessionManager.getSession(currentSessionId);
|
||||||
|
const participant = liveSession
|
||||||
|
? sessionManager.getParticipantForWs(liveSession, ws)
|
||||||
|
: null;
|
||||||
|
if (!isMessageAllowedForParticipant(participant, type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "input": {
|
||||||
|
const inputData = data as string;
|
||||||
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
|
const inputStream = liveSession?.sshStream;
|
||||||
|
if (inputStream) {
|
||||||
|
try {
|
||||||
|
inputStream.write(Buffer.from(inputData, "utf8"));
|
||||||
|
} catch {
|
||||||
|
inputStream.write(Buffer.from(inputData, "latin1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ping":
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
wss.on("connection", async (ws: WebSocket, req) => {
|
wss.on("connection", async (ws: WebSocket, req) => {
|
||||||
let userId: string | undefined;
|
let userId: string | undefined;
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
const urlObj = new URL(req.url || "", "http://localhost");
|
||||||
|
const shareToken = urlObj.searchParams.get("shareToken");
|
||||||
|
|
||||||
|
if (shareToken) {
|
||||||
|
await handleShareTokenConnection(ws, req, shareToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
|
|
||||||
@@ -126,7 +282,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const urlObj = new URL(req.url || "", "http://localhost");
|
|
||||||
const qp = urlObj.searchParams.get("token");
|
const qp = urlObj.searchParams.get("token");
|
||||||
if (qp) token = qp;
|
if (qp) token = qp;
|
||||||
}
|
}
|
||||||
@@ -242,11 +397,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const session = sessionManager.getSession(currentSessionId);
|
const session = sessionManager.getSession(currentSessionId);
|
||||||
if (session?.isConnected) {
|
if (session?.isConnected) {
|
||||||
// Only detach if this WS is still the one attached to the session.
|
const participant = sessionManager.getParticipantForWs(session, ws);
|
||||||
// If a refresh reconnected and reattached a new WS before this close
|
if (participant && !participant.isOwner) {
|
||||||
// event fired, we must not clobber that new attachment.
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
if (session.attachedWs === ws || session.attachedWs === null) {
|
} else {
|
||||||
sessionManager.detachWs(currentSessionId);
|
// Only detach if this WS is still the owner's attached socket, or
|
||||||
|
// no owner is currently attached. If a refresh reconnected and
|
||||||
|
// reattached a new WS before this close event fired, we must not
|
||||||
|
// clobber that new attachment.
|
||||||
|
const ownerStillAttached = Array.from(
|
||||||
|
session.participants.values(),
|
||||||
|
).some((p) => p.isOwner && p.ws !== ws);
|
||||||
|
if (!ownerStillAttached) {
|
||||||
|
sessionManager.detachWs(currentSessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
@@ -295,6 +459,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
const { type, data } = parsed;
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
// Server-side gate: non-owner participants (read-only or read-write
|
||||||
|
// guests/joiners) may only send input/ping/disconnect - everything else
|
||||||
|
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
||||||
|
if (type !== "joinSharedSession") {
|
||||||
|
const gateSession = currentSessionId
|
||||||
|
? sessionManager.getSession(currentSessionId)
|
||||||
|
: null;
|
||||||
|
const gateParticipant = gateSession
|
||||||
|
? sessionManager.getParticipantForWs(gateSession, ws)
|
||||||
|
: null;
|
||||||
|
if (!isMessageAllowedForParticipant(gateParticipant, type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "connectToHost": {
|
case "connectToHost": {
|
||||||
const connectData = data as ConnectToHostData;
|
const connectData = data as ConnectToHostData;
|
||||||
@@ -445,7 +624,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "disconnect":
|
case "disconnect": {
|
||||||
|
const disconnectSession = currentSessionId
|
||||||
|
? sessionManager.getSession(currentSessionId)
|
||||||
|
: null;
|
||||||
|
const disconnectParticipant = disconnectSession
|
||||||
|
? sessionManager.getParticipantForWs(disconnectSession, ws)
|
||||||
|
: null;
|
||||||
|
if (disconnectParticipant && !disconnectParticipant.isOwner) {
|
||||||
|
if (currentSessionId) {
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
currentSessionId = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
@@ -454,6 +646,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn = null;
|
sshConn = null;
|
||||||
sshStream = null;
|
sshStream = null;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "get_cwd": {
|
case "get_cwd": {
|
||||||
const activeConn =
|
const activeConn =
|
||||||
@@ -474,10 +667,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
execStream.stderr.on("data", () => {});
|
execStream.stderr.on("data", () => {});
|
||||||
execStream.on("close", () => {
|
execStream.on("close", () => {
|
||||||
const cwd = stdout.trim() || "/";
|
const cwd = stdout.trim() || "/";
|
||||||
const attachedWs =
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
ws.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
|
||||||
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -517,10 +708,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
execStream.stderr.on("data", () => {});
|
execStream.stderr.on("data", () => {});
|
||||||
execStream.on("close", () => {
|
execStream.on("close", () => {
|
||||||
const resolvedPath = stdout.trim() || requestedPath;
|
const resolvedPath = stdout.trim() || requestedPath;
|
||||||
const attachedWs =
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
ws.send(
|
||||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
|
||||||
attachedWs.send(
|
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "open_file_in_editor",
|
type: "open_file_in_editor",
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
@@ -1001,6 +1190,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "joinSharedSession": {
|
||||||
|
const joinData = data as { shareId: string; tabInstanceId?: string };
|
||||||
|
try {
|
||||||
|
const shareRepo = createCurrentSessionShareRepository();
|
||||||
|
const share = await shareRepo.findActiveById(joinData.shareId);
|
||||||
|
if (
|
||||||
|
!share ||
|
||||||
|
share.shareType !== "user" ||
|
||||||
|
share.targetUserId !== userId ||
|
||||||
|
share.protocol !== "ssh"
|
||||||
|
) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Share not found or not accessible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
share.hostId,
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Share not found or not accessible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedSession = sessionManager.joinAsParticipant(
|
||||||
|
share.sessionId,
|
||||||
|
ws,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
permissionLevel: share.permissionLevel as
|
||||||
|
| "read-write"
|
||||||
|
| "read-only",
|
||||||
|
tabInstanceId: joinData.tabInstanceId,
|
||||||
|
shareId: share.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!joinedSession) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Shared session is no longer active",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSessionId = share.sessionId;
|
||||||
|
sshStream = joinedSession.sshStream;
|
||||||
|
sshConn = joinedSession.sshConn;
|
||||||
|
isConnecting = false;
|
||||||
|
isConnected = true;
|
||||||
|
|
||||||
|
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||||
|
shareRepo
|
||||||
|
.recordParticipantJoin(share.id, userId, null)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const buffered = sessionManager.getBuffer(joinedSession);
|
||||||
|
if (buffered) {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionAttached",
|
||||||
|
sessionId: share.sessionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "connected", message: "Joined session" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to join shared session", error, {
|
||||||
|
operation: "terminal_join_shared_session_error",
|
||||||
|
userId,
|
||||||
|
shareId: joinData.shareId,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to join shared session",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sshLogger.warn("Unknown message type received", {
|
sshLogger.warn("Unknown message type received", {
|
||||||
operation: "websocket_message_unknown_type",
|
operation: "websocket_message_unknown_type",
|
||||||
@@ -1288,39 +1576,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
const connectsViaJumpHosts = !!(
|
||||||
|
hostConfig.jumpHosts &&
|
||||||
|
hostConfig.jumpHosts.length > 0 &&
|
||||||
|
hostConfig.userId
|
||||||
|
);
|
||||||
|
|
||||||
let connectHost = ip;
|
let connectHost = ip;
|
||||||
try {
|
if (connectsViaJumpHosts) {
|
||||||
const resolution = await resolveHostForSshConnect(ip);
|
// The target is only reachable through the jump host's network (e.g. a
|
||||||
connectHost = resolution.host;
|
// VPN-only address), so DNS must be resolved there, not on this host.
|
||||||
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
sendLog(
|
||||||
sendLog(
|
"dns",
|
||||||
"dns",
|
"info",
|
||||||
"success",
|
`Skipping local address resolution of ${ip} (resolved by jump host)`,
|
||||||
`Resolved ${ip} to ${resolution.resolvedAddress}`,
|
|
||||||
{ attempts: resolution.attempts },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
sshLogger.error("SSH hostname resolution failed", error, {
|
|
||||||
operation: "terminal_dns_resolve",
|
|
||||||
hostId: id,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
transient: isRetriableDnsError(error),
|
|
||||||
});
|
|
||||||
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "error",
|
|
||||||
message: isRetriableDnsError(error)
|
|
||||||
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
|
|
||||||
: "SSH error: Could not resolve hostname from the Termix server container.",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
cleanupAuthState(connectionTimeout);
|
} else {
|
||||||
return;
|
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||||||
|
try {
|
||||||
|
const resolution = await resolveHostForSshConnect(ip);
|
||||||
|
connectHost = resolution.host;
|
||||||
|
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
||||||
|
sendLog(
|
||||||
|
"dns",
|
||||||
|
"success",
|
||||||
|
`Resolved ${ip} to ${resolution.resolvedAddress}`,
|
||||||
|
{ attempts: resolution.attempts },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
sshLogger.error("SSH hostname resolution failed", error, {
|
||||||
|
operation: "terminal_dns_resolve",
|
||||||
|
hostId: id,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
transient: isRetriableDnsError(error),
|
||||||
|
});
|
||||||
|
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: isRetriableDnsError(error)
|
||||||
|
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
|
||||||
|
: "SSH error: Could not resolve hostname from the Termix server container.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cleanupAuthState(connectionTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||||
|
|
||||||
@@ -1619,12 +1924,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||||
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
type: "data",
|
||||||
session.attachedWs.send(
|
data: utf8String,
|
||||||
JSON.stringify({ type: "data", data: utf8String }),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Error encoding terminal data", error, {
|
sshLogger.error("Error encoding terminal data", error, {
|
||||||
@@ -1636,34 +1939,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
sessionManager.bufferOutput(boundSessionId!, fallback);
|
sessionManager.bufferOutput(boundSessionId!, fallback);
|
||||||
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
type: "data",
|
||||||
session.attachedWs.send(
|
data: fallback,
|
||||||
JSON.stringify({ type: "data", data: fallback }),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", (code: number | null) => {
|
stream.on("close", (code: number | null) => {
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
if (session) {
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "session_ended",
|
||||||
type: "session_ended",
|
code,
|
||||||
code,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "disconnected",
|
||||||
type: "disconnected",
|
message: "Connection lost",
|
||||||
message: "Connection lost",
|
graceful: true,
|
||||||
graceful: true,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (boundSessionId) {
|
if (boundSessionId) {
|
||||||
@@ -1683,13 +1980,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
if (session) {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "error",
|
||||||
type: "error",
|
message: "SSH stream error: " + err.message,
|
||||||
message: "SSH stream error: " + err.message,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2014,7 +2309,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sendLog(
|
sendLog(
|
||||||
"auth",
|
"auth",
|
||||||
"error",
|
"error",
|
||||||
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
|
`Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity (check tailscale.com/s/ssh for the check/action ACL syntax). If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||||
);
|
);
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
@@ -2024,8 +2319,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message:
|
message: `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
|
||||||
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
|
|||||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||||
const MAX_SESSIONS_PER_USER = 10;
|
const MAX_SESSIONS_PER_USER = 10;
|
||||||
|
|
||||||
|
export interface SessionParticipant {
|
||||||
|
ws: WebSocket;
|
||||||
|
userId: string | null; // null for anonymous link guests
|
||||||
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
isOwner: boolean;
|
||||||
|
guestLabel?: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
joinedViaShareId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -32,7 +42,7 @@ export interface TerminalSession {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|
||||||
attachedWs: WebSocket | null;
|
participants: Map<string, SessionParticipant>;
|
||||||
lastDetachedAt: number | null;
|
lastDetachedAt: number | null;
|
||||||
detachTimeout: NodeJS.Timeout | null;
|
detachTimeout: NodeJS.Timeout | null;
|
||||||
|
|
||||||
@@ -48,6 +58,33 @@ export interface TerminalSession {
|
|||||||
sessionLoggingEnabled: boolean;
|
sessionLoggingEnabled: boolean;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
lastPersistedBytes: number;
|
lastPersistedBytes: number;
|
||||||
|
terminatedByOwner: boolean;
|
||||||
|
terminationReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message types a non-owner participant may legally send. */
|
||||||
|
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||||
|
"input",
|
||||||
|
"ping",
|
||||||
|
"disconnect",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side gate for whether a participant may send a given WS message
|
||||||
|
* type. The owner may send anything; non-owners are limited to input (if
|
||||||
|
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||||
|
* is unit-testable without a real WebSocketServer.
|
||||||
|
*/
|
||||||
|
export function isMessageAllowedForParticipant(
|
||||||
|
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
|
||||||
|
messageType: string,
|
||||||
|
): boolean {
|
||||||
|
if (!participant || participant.isOwner) return true;
|
||||||
|
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
|
||||||
|
if (messageType === "input" && participant.permissionLevel === "read-only") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TerminalSessionManager {
|
class TerminalSessionManager {
|
||||||
@@ -81,7 +118,7 @@ class TerminalSessionManager {
|
|||||||
const userSessions = this.getUserSessions(userId);
|
const userSessions = this.getUserSessions(userId);
|
||||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||||
const detached = userSessions
|
const detached = userSessions
|
||||||
.filter((s) => s.attachedWs === null)
|
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.lastDetachedAt ?? a.createdAt) -
|
(a.lastDetachedAt ?? a.createdAt) -
|
||||||
@@ -109,7 +146,7 @@ class TerminalSessionManager {
|
|||||||
operation: "session_tab_duplicate_skip",
|
operation: "session_tab_duplicate_skip",
|
||||||
existingSessionId: existing.id,
|
existingSessionId: existing.id,
|
||||||
tabInstanceId,
|
tabInstanceId,
|
||||||
hasAttachedWs: existing.attachedWs !== null,
|
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return existing.id;
|
return existing.id;
|
||||||
@@ -151,7 +188,7 @@ class TerminalSessionManager {
|
|||||||
rows,
|
rows,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
attachedWs: null,
|
participants: new Map(),
|
||||||
lastDetachedAt: null,
|
lastDetachedAt: null,
|
||||||
detachTimeout: null,
|
detachTimeout: null,
|
||||||
outputBuffer: [],
|
outputBuffer: [],
|
||||||
@@ -166,6 +203,8 @@ class TerminalSessionManager {
|
|||||||
sessionLoggingEnabled,
|
sessionLoggingEnabled,
|
||||||
sessionStartedAt: now,
|
sessionStartedAt: now,
|
||||||
lastPersistedBytes: 0,
|
lastPersistedBytes: 0,
|
||||||
|
terminatedByOwner: false,
|
||||||
|
terminationReason: null,
|
||||||
};
|
};
|
||||||
this.sessions.set(id, session);
|
this.sessions.set(id, session);
|
||||||
|
|
||||||
@@ -199,6 +238,25 @@ class TerminalSessionManager {
|
|||||||
session.isConnected = true;
|
session.isConnected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Finds the owner's participant entry, if currently attached. */
|
||||||
|
private getOwnerParticipant(
|
||||||
|
session: TerminalSession,
|
||||||
|
): SessionParticipant | null {
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.isOwner) return participant;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOwnerEntry(
|
||||||
|
session: TerminalSession,
|
||||||
|
): [string, SessionParticipant] | null {
|
||||||
|
for (const entry of session.participants.entries()) {
|
||||||
|
if (entry[1].isOwner) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
attachWs(
|
attachWs(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -234,8 +292,9 @@ class TerminalSessionManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerParticipant = this.getOwnerParticipant(session);
|
||||||
const isDetached =
|
const isDetached =
|
||||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||||
const isOriginalTab =
|
const isOriginalTab =
|
||||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||||
tabInstanceId;
|
tabInstanceId;
|
||||||
@@ -282,9 +341,10 @@ class TerminalSessionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.attachedWs && session.attachedWs !== ws) {
|
const ownerEntry = this.getOwnerEntry(session);
|
||||||
|
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||||
try {
|
try {
|
||||||
session.attachedWs.send(
|
ownerEntry[1].ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "sessionTakenOver",
|
type: "sessionTakenOver",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -294,7 +354,7 @@ class TerminalSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
session.attachedWs = null;
|
session.participants.delete(ownerEntry[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.detachTimeout) {
|
if (session.detachTimeout) {
|
||||||
@@ -302,7 +362,14 @@ class TerminalSessionManager {
|
|||||||
session.detachTimeout = null;
|
session.detachTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.attachedWs = ws;
|
const participantId = crypto.randomUUID();
|
||||||
|
session.participants.set(participantId, {
|
||||||
|
ws,
|
||||||
|
userId,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
isOwner: true,
|
||||||
|
tabInstanceId,
|
||||||
|
});
|
||||||
session.attachedTabInstanceId = tabInstanceId;
|
session.attachedTabInstanceId = tabInstanceId;
|
||||||
session.lastDetachedAt = null;
|
session.lastDetachedAt = null;
|
||||||
|
|
||||||
@@ -316,6 +383,110 @@ class TerminalSessionManager {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||||
|
* Purely additive - never evicts the owner or any other participant.
|
||||||
|
*/
|
||||||
|
joinAsParticipant(
|
||||||
|
sessionId: string,
|
||||||
|
ws: WebSocket,
|
||||||
|
opts: {
|
||||||
|
userId: string | null;
|
||||||
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
guestLabel?: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
shareId?: string;
|
||||||
|
},
|
||||||
|
): TerminalSession | null {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || !session.isConnected) return null;
|
||||||
|
|
||||||
|
const participantId = crypto.randomUUID();
|
||||||
|
session.participants.set(participantId, {
|
||||||
|
ws,
|
||||||
|
userId: opts.userId,
|
||||||
|
permissionLevel: opts.permissionLevel,
|
||||||
|
isOwner: false,
|
||||||
|
guestLabel: opts.guestLabel,
|
||||||
|
tabInstanceId: opts.tabInstanceId,
|
||||||
|
joinedViaShareId: opts.shareId,
|
||||||
|
});
|
||||||
|
|
||||||
|
sshLogger.info("Participant joined shared session", {
|
||||||
|
operation: "session_join_participant",
|
||||||
|
sessionId,
|
||||||
|
userId: opts.userId,
|
||||||
|
permissionLevel: opts.permissionLevel,
|
||||||
|
shareId: opts.shareId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||||
|
broadcast(sessionId: string, message: object): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
try {
|
||||||
|
participant.ws.send(payload);
|
||||||
|
} catch {
|
||||||
|
/* ignore individual send failures, keep broadcasting to the rest */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the participant entry (owner or not) for a given socket. */
|
||||||
|
getParticipantForWs(
|
||||||
|
session: TerminalSession,
|
||||||
|
ws: WebSocket,
|
||||||
|
): SessionParticipant | null {
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.ws === ws) return participant;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a non-owner participant's socket. No detach timeout or session
|
||||||
|
* destruction side effects - a guest leaving must never end the session.
|
||||||
|
*/
|
||||||
|
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
for (const [id, participant] of session.participants.entries()) {
|
||||||
|
if (participant.ws === ws && !participant.isOwner) {
|
||||||
|
session.participants.delete(id);
|
||||||
|
sshLogger.info("Participant left shared session", {
|
||||||
|
operation: "session_leave_participant",
|
||||||
|
sessionId,
|
||||||
|
userId: participant.userId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcasts termination to all guests, then destroys the session. */
|
||||||
|
ownerEndSession(sessionId: string, reason: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||||
|
session.terminatedByOwner = true;
|
||||||
|
session.terminationReason = reason;
|
||||||
|
|
||||||
|
sshLogger.info("Owner ended shared session", {
|
||||||
|
operation: "session_owner_end",
|
||||||
|
sessionId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.destroySession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
detachWs(sessionId: string): void {
|
detachWs(sessionId: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -325,7 +496,10 @@ class TerminalSessionManager {
|
|||||||
session.detachTimeout = null;
|
session.detachTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.attachedWs = null;
|
const ownerEntry = this.getOwnerEntry(session);
|
||||||
|
if (ownerEntry) {
|
||||||
|
session.participants.delete(ownerEntry[0]);
|
||||||
|
}
|
||||||
session.lastDetachedAt = Date.now();
|
session.lastDetachedAt = Date.now();
|
||||||
|
|
||||||
// Persist log immediately when the user detaches so it appears right away,
|
// Persist log immediately when the user detaches so it appears right away,
|
||||||
@@ -365,6 +539,23 @@ class TerminalSessionManager {
|
|||||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.isOwner) continue;
|
||||||
|
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
try {
|
||||||
|
participant.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionExpired",
|
||||||
|
sessionId,
|
||||||
|
message: "Session has ended",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.participants.clear();
|
||||||
|
|
||||||
if (session.sshStream) {
|
if (session.sshStream) {
|
||||||
try {
|
try {
|
||||||
session.sshStream.end();
|
session.sshStream.end();
|
||||||
@@ -440,12 +631,16 @@ class TerminalSessionManager {
|
|||||||
recordingPath: session.recordingPath,
|
recordingPath: session.recordingPath,
|
||||||
protocol: "ssh",
|
protocol: "ssh",
|
||||||
format: "asciicast",
|
format: "asciicast",
|
||||||
|
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||||
|
terminationReason: session.terminationReason ?? undefined,
|
||||||
});
|
});
|
||||||
session.recordingId = created.id;
|
session.recordingId = created.id;
|
||||||
} else {
|
} else {
|
||||||
await repo.updateEnded(session.recordingId, {
|
await repo.updateEnded(session.recordingId, {
|
||||||
endedAt: new Date(endedAt).toISOString(),
|
endedAt: new Date(endedAt).toISOString(),
|
||||||
duration,
|
duration,
|
||||||
|
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||||
|
terminationReason: session.terminationReason ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -569,10 +764,10 @@ class TerminalSessionManager {
|
|||||||
for (const [id, session] of this.sessions) {
|
for (const [id, session] of this.sessions) {
|
||||||
if (!session.isConnected) continue;
|
if (!session.isConnected) continue;
|
||||||
|
|
||||||
if (
|
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||||
session.attachedWs &&
|
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||||
session.attachedWs.readyState === WebSocket.OPEN
|
);
|
||||||
) {
|
if (hasOpenParticipant) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,60 @@ import {
|
|||||||
setGlobalLogLevel,
|
setGlobalLogLevel,
|
||||||
} from "./utils/logger.js";
|
} from "./utils/logger.js";
|
||||||
|
|
||||||
|
async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||||
|
const { createCurrentUserRepository, createCurrentRoleRepository } =
|
||||||
|
await import("./database/repositories/factory.js");
|
||||||
|
const { AuthManager } = await import("./utils/auth-manager.js");
|
||||||
|
const crypto = await import("crypto");
|
||||||
|
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const existingCount = await userRepository.countAll();
|
||||||
|
if (existingCount > 0) return;
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const { isFirstUser } = await userRepository.createFirstLocalUser({
|
||||||
|
id,
|
||||||
|
username: "local",
|
||||||
|
passwordHash: "",
|
||||||
|
isOidc: false,
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
issuerUrl: "",
|
||||||
|
authorizationUrl: "",
|
||||||
|
tokenUrl: "",
|
||||||
|
identifierPath: "",
|
||||||
|
namePath: "",
|
||||||
|
scopes: "openid email profile",
|
||||||
|
totpSecret: null,
|
||||||
|
totpEnabled: false,
|
||||||
|
totpBackupCodes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||||
|
userId: id,
|
||||||
|
roleName: isFirstUser ? "admin" : "user",
|
||||||
|
grantedBy: id,
|
||||||
|
});
|
||||||
|
} catch (roleError) {
|
||||||
|
systemLogger.error(
|
||||||
|
"Failed to assign default role to auto-provisioned local user",
|
||||||
|
roleError,
|
||||||
|
{ operation: "desktop_auto_provision_role" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthManager.getInstance().registerUser(
|
||||||
|
id,
|
||||||
|
crypto.randomBytes(32).toString("hex"),
|
||||||
|
);
|
||||||
|
|
||||||
|
systemLogger.success("Auto-provisioned local desktop user", {
|
||||||
|
operation: "desktop_auto_provision",
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const initStartTime = Date.now();
|
const initStartTime = Date.now();
|
||||||
try {
|
try {
|
||||||
@@ -61,6 +115,8 @@ import {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
process.env.VERSION = version;
|
||||||
|
|
||||||
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
|
||||||
operation: "startup",
|
operation: "startup",
|
||||||
version: version,
|
version: version,
|
||||||
@@ -105,6 +161,10 @@ import {
|
|||||||
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||||
await runSharedHostSecretsMigration();
|
await runSharedHostSecretsMigration();
|
||||||
|
|
||||||
|
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||||
|
await provisionLocalDesktopUserIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
import("./utils/opkssh-binary-manager.js").then(
|
import("./utils/opkssh-binary-manager.js").then(
|
||||||
({ OPKSSHBinaryManager }) => {
|
({ OPKSSHBinaryManager }) => {
|
||||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||||
@@ -170,6 +230,9 @@ import {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
|
||||||
|
startAnalyticsHeartbeat();
|
||||||
|
|
||||||
systemLogger.success("Termix backend started successfully", {
|
systemLogger.success("Termix backend started successfully", {
|
||||||
operation: "backend_init_complete",
|
operation: "backend_init_complete",
|
||||||
port: process.env.PORT || 4090,
|
port: process.env.PORT || 4090,
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
|
|||||||
label TEXT NOT NULL,
|
label TEXT NOT NULL,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
"order" INTEGER NOT NULL DEFAULT 0,
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
sync_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
|
|||||||
);
|
);
|
||||||
expect(writeCount).toBe(2);
|
expect(writeCount).toBe(2);
|
||||||
|
|
||||||
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
|
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
|
||||||
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
|
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
|
||||||
|
syncId: expect.any(String),
|
||||||
|
});
|
||||||
expect(writeCount).toBe(3);
|
expect(writeCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
|
|||||||
title TEXT,
|
title TEXT,
|
||||||
config TEXT NOT NULL DEFAULT '{}',
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
folder_id INTEGER,
|
folder_id INTEGER,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(writeCount).toBe(2);
|
expect(writeCount).toBe(2);
|
||||||
|
|
||||||
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
|
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
|
||||||
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
|
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
|
||||||
|
syncId: expect.any(String),
|
||||||
|
});
|
||||||
expect(writeCount).toBe(3);
|
expect(writeCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
cert_public_key TEXT,
|
cert_public_key TEXT,
|
||||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used TEXT,
|
last_used TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -87,6 +88,7 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
@@ -150,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
host_key_first_seen TEXT,
|
host_key_first_seen TEXT,
|
||||||
host_key_last_verified TEXT,
|
host_key_last_verified TEXT,
|
||||||
host_key_changed_count INTEGER DEFAULT 0,
|
host_key_changed_count INTEGER DEFAULT 0,
|
||||||
|
connection_origin TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -225,9 +229,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
expect(
|
expect(
|
||||||
await repo.credentials.findByIdForUser("user-2", created.id),
|
await repo.credentials.findByIdForUser("user-2", created.id),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
|
expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
|
||||||
true,
|
syncId: expect.any(String),
|
||||||
);
|
});
|
||||||
expect(
|
expect(
|
||||||
await repo.credentials.findByIdForUser("user-1", created.id),
|
await repo.credentials.findByIdForUser("user-1", created.id),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
@@ -448,7 +452,9 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
expect(updated?.name).toBe("web-1-renamed");
|
expect(updated?.name).toBe("web-1-renamed");
|
||||||
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
|
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
|
||||||
|
|
||||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||||
|
syncId: expect.any(String),
|
||||||
|
});
|
||||||
expect(await repo.hosts.findById(host.id)).toBeNull();
|
expect(await repo.hosts.findById(host.id)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -686,6 +692,8 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
.run(host.id, "user-2", "user-1");
|
.run(host.id, "user-2", "user-1");
|
||||||
|
|
||||||
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
|
||||||
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
|
expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
|
||||||
|
syncId: expect.any(String),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("HostFolderRepository", () => {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
auth_type TEXT NOT NULL,
|
auth_type TEXT NOT NULL,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -66,6 +67,7 @@ describe("HostFolderRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
@@ -129,6 +131,8 @@ describe("HostFolderRepository", () => {
|
|||||||
host_key_first_seen TEXT,
|
host_key_first_seen TEXT,
|
||||||
host_key_last_verified TEXT,
|
host_key_last_verified TEXT,
|
||||||
host_key_changed_count INTEGER DEFAULT 0,
|
host_key_changed_count INTEGER DEFAULT 0,
|
||||||
|
connection_origin TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -139,6 +143,8 @@ describe("HostFolderRepository", () => {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
|
credential_id INTEGER,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -216,6 +222,7 @@ describe("HostFolderRepository", () => {
|
|||||||
"prod",
|
"prod",
|
||||||
"#abcdef",
|
"#abcdef",
|
||||||
"folder",
|
"folder",
|
||||||
|
undefined,
|
||||||
"2026-02-01T00:00:00.000Z",
|
"2026-02-01T00:00:00.000Z",
|
||||||
),
|
),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@@ -228,6 +235,7 @@ describe("HostFolderRepository", () => {
|
|||||||
"new",
|
"new",
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
"2026-03-01T00:00:00.000Z",
|
"2026-03-01T00:00:00.000Z",
|
||||||
),
|
),
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
@@ -237,6 +245,28 @@ describe("HostFolderRepository", () => {
|
|||||||
expect(writes).toBe(2);
|
expect(writes).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("assigns a credential to a folder and resolves it for nested paths", async () => {
|
||||||
|
const { repository } = await createRepository();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.upsertMetadata(
|
||||||
|
"user-1",
|
||||||
|
"prod",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
1,
|
||||||
|
"2026-02-01T00:00:00.000Z",
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
created: false,
|
||||||
|
folder: { credentialId: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const folders = await repository.listFolders("user-1");
|
||||||
|
const prodFolder = folders.find((f) => f.name === "prod");
|
||||||
|
expect(prodFolder?.credentialId).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
it("lists and deletes hosts and folder records in a folder tree", async () => {
|
||||||
let writes = 0;
|
let writes = 0;
|
||||||
const { repository, sqlite } = await createRepository(() => {
|
const { repository, sqlite } = await createRepository(() => {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
@@ -124,6 +125,8 @@ describe("HostResolutionRepository", () => {
|
|||||||
host_key_first_seen TEXT,
|
host_key_first_seen TEXT,
|
||||||
host_key_last_verified TEXT,
|
host_key_last_verified TEXT,
|
||||||
host_key_changed_count INTEGER DEFAULT 0,
|
host_key_changed_count INTEGER DEFAULT 0,
|
||||||
|
connection_origin TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -147,6 +150,7 @@ describe("HostResolutionRepository", () => {
|
|||||||
cert_public_key TEXT,
|
cert_public_key TEXT,
|
||||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used TEXT,
|
last_used TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -165,6 +169,18 @@ describe("HostResolutionRepository", () => {
|
|||||||
override_credential_id INTEGER
|
override_credential_id INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ssh_folders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
icon TEXT,
|
||||||
|
credential_id INTEGER,
|
||||||
|
sync_id TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
INSERT INTO users (id, username, password_hash)
|
INSERT INTO users (id, username, password_hash)
|
||||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
INSERT INTO ssh_data (
|
INSERT INTO ssh_data (
|
||||||
@@ -185,6 +201,11 @@ describe("HostResolutionRepository", () => {
|
|||||||
host_id, user_id, granted_by, permission_level, override_credential_id
|
host_id, user_id, granted_by, permission_level, override_credential_id
|
||||||
)
|
)
|
||||||
VALUES (1, 'user-2', 'user-1', 'execute', 8);
|
VALUES (1, 'user-2', 'user-1', 'execute', 8);
|
||||||
|
INSERT INTO ssh_folders (user_id, name, credential_id)
|
||||||
|
VALUES
|
||||||
|
('user-1', 'switches', 7),
|
||||||
|
('user-1', 'switches / floor1', NULL),
|
||||||
|
('user-1', 'no-cred', NULL);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return new HostResolutionRepository(context, onWrite);
|
return new HostResolutionRepository(context, onWrite);
|
||||||
@@ -492,4 +513,24 @@ describe("HostResolutionRepository", () => {
|
|||||||
repository.findOverrideCredentialId(1, "user-1"),
|
repository.findOverrideCredentialId(1, "user-1"),
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findFolderCredentialId("user-1", "switches"),
|
||||||
|
).resolves.toBe(7);
|
||||||
|
await expect(
|
||||||
|
repository.findFolderCredentialId("user-1", "switches / floor1"),
|
||||||
|
).resolves.toBe(7);
|
||||||
|
await expect(
|
||||||
|
repository.findFolderCredentialId("user-1", "no-cred"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findFolderCredentialId("user-1", "unknown"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findFolderCredentialId("user-1", ""),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
|
||||||
|
|
||||||
|
describe("SessionShareRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(
|
||||||
|
onWrite?: () => void | Promise<void>,
|
||||||
|
): Promise<SessionShareRepository> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
context.sqlite?.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ssh_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||||
|
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||||
|
VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2');
|
||||||
|
`);
|
||||||
|
|
||||||
|
return new SessionShareRepository(context, onWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
|
||||||
|
const FAR_PAST = "2000-01-01T00:00:00.000Z";
|
||||||
|
|
||||||
|
it("creates a share and finds it by id", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
const created = await repo.create({
|
||||||
|
id: "share-1",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-abc",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
id: "share-1",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
linkToken: "token-abc",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await repo.findById("share-1");
|
||||||
|
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken excludes revoked shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-revoked",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-revoked",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
|
||||||
|
|
||||||
|
await repo.revoke("share-revoked", "owner-1");
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken excludes expired shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-expired",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-expired",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-expired")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "vnc",
|
||||||
|
sessionId: "guac-session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-active",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await repo.findByLinkToken("token-active");
|
||||||
|
expect(found).toMatchObject({
|
||||||
|
id: "share-active",
|
||||||
|
protocol: "vnc",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
await repo.create({
|
||||||
|
id: "share-user-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "guest-1",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expired user share for the same target - must be excluded
|
||||||
|
await repo.create({
|
||||||
|
id: "share-user-expired",
|
||||||
|
hostId: 2,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "guest-1",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link share, not targeting a user - must be excluded even though it's active
|
||||||
|
await repo.create({
|
||||||
|
id: "share-link-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-3",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-unrelated",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shares = await repo.findSharesTargetingUser("guest-1");
|
||||||
|
expect(shares).toHaveLength(1);
|
||||||
|
expect(shares[0]).toMatchObject({
|
||||||
|
id: "share-user-active",
|
||||||
|
hostName: "host-one",
|
||||||
|
ownerUsername: "alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revoke only affects the requesting owner's own share", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-owned",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-owned",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
|
||||||
|
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokeAsAdmin revokes regardless of owner", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-admin-target",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-admin",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
|
||||||
|
expect(await repo.findByLinkToken("token-admin")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteExpiredShares removes only expired rows", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-old",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-old",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-current",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-current",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedCount = await repo.deleteExpiredShares();
|
||||||
|
expect(deletedCount).toBe(1);
|
||||||
|
expect(await repo.findById("share-old")).toBeNull();
|
||||||
|
expect(await repo.findById("share-current")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-touch",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-touch",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
|
||||||
|
let row = await repo.findById("share-touch");
|
||||||
|
expect(row?.joinCount).toBe(1);
|
||||||
|
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
|
||||||
|
row = await repo.findById("share-touch");
|
||||||
|
expect(row?.joinCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and closes participant joins", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-participants",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-participants",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const participant = await repo.recordParticipantJoin(
|
||||||
|
"share-participants",
|
||||||
|
null,
|
||||||
|
"Guest",
|
||||||
|
);
|
||||||
|
expect(participant).toMatchObject({
|
||||||
|
shareId: "share-participants",
|
||||||
|
userId: null,
|
||||||
|
guestLabel: "Guest",
|
||||||
|
});
|
||||||
|
expect(participant.leftAt).toBeNull();
|
||||||
|
|
||||||
|
await repo.recordParticipantLeave(participant.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write hook fires on mutating operations", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.create({
|
||||||
|
id: "share-write-hook",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-write-hook",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
|
||||||
|
await repo.revoke("share-write-hook", "owner-1");
|
||||||
|
expect(writeCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSharesForHost removes all shares for a host", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-1a",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h1a",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-1b",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h1b",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-2",
|
||||||
|
hostId: 2,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-3",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h2",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.deleteSharesForHost(1)).toBe(2);
|
||||||
|
expect(await repo.findById("share-host-2")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
folder TEXT,
|
folder TEXT,
|
||||||
"order" INTEGER NOT NULL DEFAULT 0,
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
host_filter TEXT
|
host_filter TEXT
|
||||||
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
color TEXT,
|
color TEXT,
|
||||||
icon TEXT,
|
icon TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
|
||||||
|
|
||||||
|
describe("SyncTombstoneRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(
|
||||||
|
onWrite?: () => void | Promise<void>,
|
||||||
|
): Promise<SyncTombstoneRepository> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
context.sqlite?.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sync_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
sync_id TEXT NOT NULL,
|
||||||
|
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
|
`);
|
||||||
|
|
||||||
|
return new SyncTombstoneRepository(context, onWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("records a tombstone and lists it back for the owning user", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.record("user-1", "hosts", "sync-abc");
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
|
||||||
|
const rows = await repo.listSince("user-1", "hosts", null);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
userId: "user-1",
|
||||||
|
entityType: "hosts",
|
||||||
|
syncId: "sync-abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not record a tombstone for an empty syncId", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.record("user-1", "hosts", "");
|
||||||
|
const rows = await repo.listSince("user-1", "hosts", null);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
|
||||||
|
const rows = await repo.listSince("user-1", "hosts", null);
|
||||||
|
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordMany is a no-op when given no syncIds", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.recordMany("user-1", "hosts", []);
|
||||||
|
expect(writeCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes listSince by userId and entityType", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.record("user-1", "hosts", "sync-1");
|
||||||
|
await repo.record("user-1", "snippets", "sync-2");
|
||||||
|
await repo.record("user-2", "hosts", "sync-3");
|
||||||
|
|
||||||
|
const rows = await repo.listSince("user-1", "hosts", null);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].syncId).toBe("sync-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters listSince by the since timestamp", async () => {
|
||||||
|
const adapterLocal = new TestSqliteDatabase();
|
||||||
|
adapter = adapterLocal;
|
||||||
|
const context = await adapterLocal.connect();
|
||||||
|
context.sqlite?.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sync_tombstones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
sync_id TEXT NOT NULL,
|
||||||
|
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('user-1', 'alice', 'hash');
|
||||||
|
|
||||||
|
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
|
||||||
|
VALUES
|
||||||
|
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
|
||||||
|
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
|
||||||
|
`);
|
||||||
|
const repo = new SyncTombstoneRepository(context);
|
||||||
|
|
||||||
|
const rows = await repo.listSince(
|
||||||
|
"user-1",
|
||||||
|
"hosts",
|
||||||
|
"2026-03-01T00:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].syncId).toBe("new");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
@@ -112,6 +113,8 @@ describe("UserDataExportRepository", () => {
|
|||||||
host_key_first_seen TEXT,
|
host_key_first_seen TEXT,
|
||||||
host_key_last_verified TEXT,
|
host_key_last_verified TEXT,
|
||||||
host_key_changed_count INTEGER DEFAULT 0,
|
host_key_changed_count INTEGER DEFAULT 0,
|
||||||
|
connection_origin TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -135,6 +138,7 @@ describe("UserDataExportRepository", () => {
|
|||||||
cert_public_key TEXT,
|
cert_public_key TEXT,
|
||||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used TEXT,
|
last_used TEXT,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ describe("UserPreferenceRepository", () => {
|
|||||||
hidden_rail_tabs TEXT,
|
hidden_rail_tabs TEXT,
|
||||||
compact_host_view INTEGER,
|
compact_host_view INTEGER,
|
||||||
status_color_scheme TEXT,
|
status_color_scheme TEXT,
|
||||||
|
custom_themes TEXT,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
|
|||||||
valid_principals TEXT,
|
valid_principals TEXT,
|
||||||
key_type TEXT,
|
key_type TEXT,
|
||||||
shared INTEGER NOT NULL DEFAULT 0,
|
shared INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sync_id TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
|
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
|
||||||
expect(await repo.deleteById(1)).toBe(true);
|
expect(await repo.deleteById(1)).toEqual({ syncId: null });
|
||||||
expect(await repo.deleteById(1)).toBe(false);
|
expect(await repo.deleteById(1)).toBeNull();
|
||||||
expect(await repo.findById(1)).toBeNull();
|
expect(await repo.findById(1)).toBeNull();
|
||||||
expect(writeCount).toBe(2);
|
expect(writeCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isValidEntityType,
|
||||||
|
stripWritePayload,
|
||||||
|
} from "../../../database/routes/sync.js";
|
||||||
|
|
||||||
|
describe("isValidEntityType", () => {
|
||||||
|
it("accepts every whitelisted sync entity type", () => {
|
||||||
|
for (const type of [
|
||||||
|
"hosts",
|
||||||
|
"sshCredentials",
|
||||||
|
"sshFolders",
|
||||||
|
"snippets",
|
||||||
|
"snippetFolders",
|
||||||
|
"vaultProfiles",
|
||||||
|
"dashboardServiceLinks",
|
||||||
|
"homepageItems",
|
||||||
|
]) {
|
||||||
|
expect(isValidEntityType(type)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown or non-string entity types", () => {
|
||||||
|
expect(isValidEntityType("hostAccess")).toBe(false);
|
||||||
|
expect(isValidEntityType("")).toBe(false);
|
||||||
|
expect(isValidEntityType(undefined)).toBe(false);
|
||||||
|
expect(isValidEntityType(42)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripWritePayload", () => {
|
||||||
|
it("strips id, userId, and syncId from every entity type", () => {
|
||||||
|
const payload = {
|
||||||
|
id: 1,
|
||||||
|
userId: "user-1",
|
||||||
|
syncId: "abc",
|
||||||
|
name: "prod-db",
|
||||||
|
};
|
||||||
|
expect(stripWritePayload("sshFolders", payload)).toEqual({
|
||||||
|
name: "prod-db",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also strips desktop-only fields flagged read-only for hosts", () => {
|
||||||
|
const payload = {
|
||||||
|
id: 1,
|
||||||
|
userId: "user-1",
|
||||||
|
syncId: "abc",
|
||||||
|
name: "web",
|
||||||
|
connectionOrigin: "remote",
|
||||||
|
};
|
||||||
|
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate the original payload object", () => {
|
||||||
|
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
|
||||||
|
stripWritePayload("snippets", payload);
|
||||||
|
expect(payload).toEqual({
|
||||||
|
id: 1,
|
||||||
|
userId: "user-1",
|
||||||
|
syncId: "abc",
|
||||||
|
name: "x",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
|
|||||||
|
|
||||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||||
|
const termixMeta = {
|
||||||
|
termixConnectId: "connect-1",
|
||||||
|
hostId: 7,
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
protocol: "rdp" as const,
|
||||||
|
};
|
||||||
|
const token = tokenService.createRdpToken(
|
||||||
|
"windows.example.test",
|
||||||
|
"Administrator",
|
||||||
|
"secret",
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
termixMeta,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||||
|
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||||
|
const decrypted = tokenService.decryptToken(token);
|
||||||
|
|
||||||
|
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||||
|
expect(decrypted?.connection.type).toBeUndefined();
|
||||||
|
expect(decrypted?.connection.readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||||
|
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||||
|
const decrypted = tokenService.decryptToken(token);
|
||||||
|
|
||||||
|
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||||
|
expect(decrypted?.connection.readOnly).toBe(false);
|
||||||
|
expect(decrypted?.recording).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
|
|||||||
credentials: new Map<string, Record<string, unknown>>(),
|
credentials: new Map<string, Record<string, unknown>>(),
|
||||||
sharedSecret: null as Record<string, unknown> | null,
|
sharedSecret: null as Record<string, unknown> | null,
|
||||||
auditCalls: [] as Record<string, unknown>[],
|
auditCalls: [] as Record<string, unknown>[],
|
||||||
|
folderCredentialId: null as number | null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
@@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
findOverrideCredentialId: async () => state.overrideCredentialId,
|
findOverrideCredentialId: async () => state.overrideCredentialId,
|
||||||
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||||
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
state.credentials.get(`${credentialId}:${userId}`) ?? null,
|
||||||
|
findFolderCredentialId: async () => state.folderCredentialId,
|
||||||
}),
|
}),
|
||||||
createCurrentVaultProfileRepository: () => ({
|
createCurrentVaultProfileRepository: () => ({
|
||||||
findById: async () => null,
|
findById: async () => null,
|
||||||
@@ -101,6 +103,7 @@ beforeEach(() => {
|
|||||||
state.credentials.clear();
|
state.credentials.clear();
|
||||||
state.sharedSecret = null;
|
state.sharedSecret = null;
|
||||||
state.auditCalls = [];
|
state.auditCalls = [];
|
||||||
|
state.folderCredentialId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveHostById", () => {
|
describe("resolveHostById", () => {
|
||||||
@@ -138,6 +141,63 @@ describe("resolveHostById", () => {
|
|||||||
expect(host.sudoPassword).toBe("owner-sudo");
|
expect(host.sudoPassword).toBe("owner-sudo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
|
||||||
|
state.host = baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: null,
|
||||||
|
folder: "switches",
|
||||||
|
username: "",
|
||||||
|
password: null,
|
||||||
|
});
|
||||||
|
state.folderCredentialId = 11;
|
||||||
|
state.credentials.set("11:owner", {
|
||||||
|
id: 11,
|
||||||
|
username: "folder-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "folder-pass",
|
||||||
|
privateKey: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "owner")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.password).toBe("folder-pass");
|
||||||
|
expect(host.username).toBe("folder-user");
|
||||||
|
expect(host.authType).toBe("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the host's own credential over its folder's credential", async () => {
|
||||||
|
state.host = baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 9,
|
||||||
|
folder: "switches",
|
||||||
|
username: "",
|
||||||
|
password: null,
|
||||||
|
});
|
||||||
|
state.folderCredentialId = 11;
|
||||||
|
state.credentials.set("9:owner", {
|
||||||
|
id: 9,
|
||||||
|
username: "host-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "host-pass",
|
||||||
|
privateKey: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "owner")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.username).toBe("host-user");
|
||||||
|
expect(host.password).toBe("host-pass");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
|
||||||
state.host = baseHost({ username: "" });
|
state.host = baseHost({ username: "" });
|
||||||
state.sharedSecret = {
|
state.sharedSecret = {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseDfLines,
|
||||||
|
findWorstMountIndex,
|
||||||
|
} from "../../../../hosts/metrics/widgets/disk-collector.js";
|
||||||
|
|
||||||
|
describe("parseDfLines", () => {
|
||||||
|
it("parses df -P output into rows", () => {
|
||||||
|
const output =
|
||||||
|
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||||
|
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||||
|
const rows = parseDfLines(output);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].mount).toBe("/");
|
||||||
|
expect(rows[1].mount).toBe("/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out pseudo filesystems", () => {
|
||||||
|
const output =
|
||||||
|
"tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||||
|
"overlay 100 50 50 50% /\n" +
|
||||||
|
"/dev/sda1 100 50 50 50% /mnt/data\n";
|
||||||
|
const rows = parseDfLines(output);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].mount).toBe("/mnt/data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findWorstMountIndex", () => {
|
||||||
|
it("picks the most-utilized mount, not just the first row", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||||
|
"/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||||
|
);
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(1);
|
||||||
|
expect(worst.totalBytes).toBe(15393162788864);
|
||||||
|
expect(worst.usedBytes).toBe(15239230844928);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the only mount available", () => {
|
||||||
|
const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n");
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with invalid or zero totals", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n",
|
||||||
|
);
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns index -1 when there are no usable rows", () => {
|
||||||
|
const worst = findWorstMountIndex([]);
|
||||||
|
expect(worst.index).toBe(-1);
|
||||||
|
expect(worst.totalBytes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
currentUserId: "user-1",
|
||||||
|
globalSharingEnabled: true,
|
||||||
|
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||||
|
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||||
|
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||||
|
guacSessions: new Map<
|
||||||
|
string,
|
||||||
|
{ ownerUserId: string; hostId: number; protocol: string }
|
||||||
|
>(),
|
||||||
|
shares: new Map<string, Record<string, unknown>>(),
|
||||||
|
admins: new Set<string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
sshLogger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||||
|
req.userId = state.currentUserId;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
canAccessHost: async (
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
_action: string,
|
||||||
|
) => ({
|
||||||
|
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||||
|
}),
|
||||||
|
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||||
|
sessionManager: {
|
||||||
|
getSession: (sessionId: string) => {
|
||||||
|
const session = state.sshSessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
return { ...session };
|
||||||
|
},
|
||||||
|
ownerEndSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
|
||||||
|
getGuacSessionInfo: (guacamoleConnectionId: string) =>
|
||||||
|
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||||
|
GuacamoleTokenService: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||||
|
`join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSessionShareRepository: () => ({
|
||||||
|
create: async (input: Record<string, unknown>) => {
|
||||||
|
const row = {
|
||||||
|
...input,
|
||||||
|
createdAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
revokedAt: null,
|
||||||
|
lastJoinedAt: null,
|
||||||
|
joinCount: 0,
|
||||||
|
};
|
||||||
|
state.shares.set(input.id as string, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||||
|
findByLinkToken: async (linkToken: string) => {
|
||||||
|
for (const share of state.shares.values()) {
|
||||||
|
if (
|
||||||
|
share.linkToken === linkToken &&
|
||||||
|
!share.revokedAt &&
|
||||||
|
(share.expiresAt as string) > new Date().toISOString()
|
||||||
|
) {
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||||
|
return [...state.shares.values()].filter(
|
||||||
|
(s) =>
|
||||||
|
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
revoke: async (shareId: string, requestingUserId: string) => {
|
||||||
|
const share = state.shares.get(shareId);
|
||||||
|
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||||
|
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
revokeAsAdmin: async (shareId: string) => {
|
||||||
|
const share = state.shares.get(shareId);
|
||||||
|
if (!share) return false;
|
||||||
|
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
touchShareUsage: async () => {},
|
||||||
|
recordParticipantJoin: async () => ({ id: 1 }),
|
||||||
|
}),
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
getBoolean: async () => state.globalSharingEnabled,
|
||||||
|
}),
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostOwnerId: async (hostId: number) =>
|
||||||
|
state.hosts.get(hostId)?.userId ?? null,
|
||||||
|
findHostById: async (hostId: number) => {
|
||||||
|
const host = state.hosts.get(hostId);
|
||||||
|
if (!host) return null;
|
||||||
|
return { allowSessionSharing: host.allowSessionSharing };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: router } =
|
||||||
|
await import("../../../hosts/session-sharing/routes.js");
|
||||||
|
|
||||||
|
type RouteLayer = {
|
||||||
|
route?: {
|
||||||
|
path: string;
|
||||||
|
methods: Record<string, boolean>;
|
||||||
|
stack: {
|
||||||
|
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function findHandlers(method: string, path: string) {
|
||||||
|
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||||
|
const layer = layers.find(
|
||||||
|
(l) => l.route?.path === path && l.route.methods[method],
|
||||||
|
);
|
||||||
|
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||||
|
return layer.route.stack.map((s) => s.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReqRes(overrides: {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
const req = {
|
||||||
|
body: overrides.body ?? {},
|
||||||
|
params: overrides.params ?? {},
|
||||||
|
headers: {},
|
||||||
|
ip: overrides.ip ?? "127.0.0.1",
|
||||||
|
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
jsonBody: null as unknown,
|
||||||
|
status(code: number) {
|
||||||
|
(this as unknown as { statusCode: number }).statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload: unknown) {
|
||||||
|
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||||
|
|
||||||
|
return { req, res };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
overrides: {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
ip?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const handlers = findHandlers(method, path);
|
||||||
|
const { req, res } = makeReqRes(overrides);
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
let calledNext = false;
|
||||||
|
await handler(req, res, () => {
|
||||||
|
calledNext = true;
|
||||||
|
});
|
||||||
|
if (!calledNext) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res as unknown as {
|
||||||
|
statusCode: number;
|
||||||
|
jsonBody: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.currentUserId = "user-1";
|
||||||
|
state.globalSharingEnabled = true;
|
||||||
|
state.hosts = new Map([
|
||||||
|
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||||
|
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||||
|
]);
|
||||||
|
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||||
|
state.sshSessions = new Map([
|
||||||
|
["session-1", { userId: "user-1", isConnected: true }],
|
||||||
|
]);
|
||||||
|
state.guacSessions = new Map([
|
||||||
|
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||||
|
]);
|
||||||
|
state.shares = new Map();
|
||||||
|
state.admins = new Set();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /session-sharing/create", () => {
|
||||||
|
it("rejects a caller who does not own the live session", async () => {
|
||||||
|
state.currentUserId = "user-2";
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "You do not own this live session",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a link share for the session owner", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||||
|
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a user share when the target lacks host access", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "no-access-user",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "Target user does not have access to this host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||||
|
state.globalSharingEnabled = false;
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "Session sharing is disabled for this host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 2,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||||
|
async function createActiveLinkShare(
|
||||||
|
overrides: Partial<Record<string, unknown>> = {},
|
||||||
|
) {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()];
|
||||||
|
return share as { linkToken: string; id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.jsonBody as Record<string, unknown>;
|
||||||
|
const serialized = JSON.stringify(body).toLowerCase();
|
||||||
|
|
||||||
|
expect(body).not.toHaveProperty("hostname");
|
||||||
|
expect(body).not.toHaveProperty("ip");
|
||||||
|
expect(body).not.toHaveProperty("username");
|
||||||
|
expect(body).not.toHaveProperty("hostId");
|
||||||
|
expect(body).not.toHaveProperty("hostName");
|
||||||
|
expect(serialized).not.toContain("10.0.0");
|
||||||
|
expect(serialized).not.toContain("hostname");
|
||||||
|
expect(serialized).not.toContain('"ip"');
|
||||||
|
expect(serialized).not.toContain("username");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.jsonBody).toEqual({
|
||||||
|
protocol: "ssh",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mints a fresh join token for guac protocols", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "guac-conn-1",
|
||||||
|
protocol: "vnc",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as {
|
||||||
|
linkToken: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||||
|
token: "join-token:guac-conn-1:true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown link token", async () => {
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: "does-not-exist" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a revoked link token", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired link token", async () => {
|
||||||
|
state.shares.set("share-expired", {
|
||||||
|
id: "share-expired",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "expired-token",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||||
|
revokedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: "expired-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
state.globalSharingEnabled = false;
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /session-sharing/:shareId", () => {
|
||||||
|
it("allows the owner to revoke their own share", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-owner, non-admin caller", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
state.currentUserId = "user-2";
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to revoke someone else's share", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
state.currentUserId = "admin-1";
|
||||||
|
state.admins.add("admin-1");
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { sessionManager } =
|
const { sessionManager, isMessageAllowedForParticipant } =
|
||||||
await import("../../../hosts/terminal/session-manager.js");
|
await import("../../../hosts/terminal/session-manager.js");
|
||||||
|
|
||||||
|
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||||
|
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||||
|
return {
|
||||||
|
readyState,
|
||||||
|
send: vi.fn(),
|
||||||
|
} as unknown as import("ws").WebSocket;
|
||||||
|
}
|
||||||
|
const WS_OPEN = 1;
|
||||||
|
const WS_CLOSED = 3;
|
||||||
|
|
||||||
describe("TerminalSessionManager - session logging", () => {
|
describe("TerminalSessionManager - session logging", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
|
|||||||
sessionManager.destroySession(id);
|
sessionManager.destroySession(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockMkdir.mockResolvedValue(undefined);
|
||||||
|
mockWriteFile.mockResolvedValue(undefined);
|
||||||
|
mockCreate.mockResolvedValue({ id: 1 });
|
||||||
|
mockUpdateEnded.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConnectedSession(): string {
|
||||||
|
const id = sessionManager.createSession(
|
||||||
|
"owner-1",
|
||||||
|
1,
|
||||||
|
"host",
|
||||||
|
80,
|
||||||
|
24,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||||
|
// by attachWs/joinAsParticipant.
|
||||||
|
const session = sessionManager.getSession(id)!;
|
||||||
|
session.isConnected = true;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
guestLabel: "Guest",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session).not.toBeNull();
|
||||||
|
expect(session!.participants.size).toBe(2);
|
||||||
|
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||||
|
session!,
|
||||||
|
ownerWs,
|
||||||
|
);
|
||||||
|
expect(ownerParticipant?.isOwner).toBe(true);
|
||||||
|
expect(ownerWs.send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||||
|
expect(
|
||||||
|
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs(WS_OPEN);
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||||
|
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||||
|
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||||
|
|
||||||
|
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({ type: "data", data: "hello" }),
|
||||||
|
);
|
||||||
|
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({ type: "data", data: "hello" }),
|
||||||
|
);
|
||||||
|
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast does not throw if a socket's send throws", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const throwingWs = makeFakeWs(WS_OPEN);
|
||||||
|
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||||
|
throw new Error("send failed");
|
||||||
|
});
|
||||||
|
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast is a no-op for a nonexistent session", () => {
|
||||||
|
expect(() =>
|
||||||
|
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
sessionManager.detachWs(id);
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session?.detachTimeout).not.toBeNull();
|
||||||
|
expect(session?.lastDetachedAt).not.toBeNull();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.removeParticipant(id, guestWs);
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session).not.toBeNull();
|
||||||
|
expect(session?.detachTimeout).toBeNull();
|
||||||
|
expect(session?.participants.size).toBe(1);
|
||||||
|
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
sessionManager.removeParticipant(id, ownerWs);
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session?.participants.size).toBe(1);
|
||||||
|
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroySession cleans up all participants, not just the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
|
||||||
|
expect(guestWs.send).toHaveBeenCalled();
|
||||||
|
expect(sessionManager.getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||||
|
|
||||||
|
expect(guestWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionTerminatedByOwner",
|
||||||
|
reason: "owner ended the session",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sessionManager.getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMessageAllowedForParticipant", () => {
|
||||||
|
it("allows any message type for the owner or when there is no participant", () => {
|
||||||
|
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: true, permissionLevel: "read-write" },
|
||||||
|
"resize",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops input from a read-only participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"input",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows input from a read-write non-owner participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-write" },
|
||||||
|
"input",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows ping and disconnect for any non-owner participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"ping",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"disconnect",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||||
|
for (const type of [
|
||||||
|
"resize",
|
||||||
|
"totp_response",
|
||||||
|
"password_response",
|
||||||
|
"tmux_attach",
|
||||||
|
"tmux_detach",
|
||||||
|
"get_cwd",
|
||||||
|
"vault_start_auth",
|
||||||
|
"opkssh_start_auth",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-write" },
|
||||||
|
type,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
const mockGetBoolean = vi.fn();
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockSet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
|
||||||
|
function makeChain(resolveValue: unknown) {
|
||||||
|
const chain: Record<string, unknown> = {};
|
||||||
|
const methods = ["from", "where", "groupBy"];
|
||||||
|
for (const m of methods) {
|
||||||
|
chain[m] = vi.fn(() => chain);
|
||||||
|
}
|
||||||
|
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||||
|
Promise.resolve(resolveValue).then(cb);
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
getBoolean: mockGetBoolean,
|
||||||
|
get: mockGet,
|
||||||
|
set: mockSet,
|
||||||
|
}),
|
||||||
|
createCurrentRepositoryContext: () => ({
|
||||||
|
drizzle: {
|
||||||
|
select: vi.fn(() => makeChain([{ count: 0 }])),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/db/schema.js", () => ({
|
||||||
|
users: {},
|
||||||
|
hosts: {},
|
||||||
|
recentActivity: { type: "type", timestamp: "timestamp" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
Logger: class {
|
||||||
|
info = vi.fn();
|
||||||
|
warn = vi.fn();
|
||||||
|
error = vi.fn();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: { post: mockPost },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("analytics", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
|
||||||
|
mockGetBoolean.mockResolvedValue(true);
|
||||||
|
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
const result = await isAnalyticsEnabled();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||||
|
mockGet.mockResolvedValue("existing-id");
|
||||||
|
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
const id = await getOrCreateInstanceId();
|
||||||
|
|
||||||
|
expect(id).toBe("existing-id");
|
||||||
|
expect(mockSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||||
|
mockGet.mockResolvedValue(null);
|
||||||
|
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
const id = await getOrCreateInstanceId();
|
||||||
|
|
||||||
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||||
|
delete process.env.POSTHOG_API_KEY;
|
||||||
|
const { collectAndSendHeartbeat } =
|
||||||
|
await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
await collectAndSendHeartbeat();
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||||
|
process.env.POSTHOG_API_KEY = "phc_test";
|
||||||
|
mockGetBoolean.mockResolvedValue(false);
|
||||||
|
const { collectAndSendHeartbeat } =
|
||||||
|
await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
await collectAndSendHeartbeat();
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
|
||||||
|
process.env.POSTHOG_API_KEY = "phc_test";
|
||||||
|
mockGetBoolean.mockResolvedValue(true);
|
||||||
|
mockGet.mockResolvedValue("instance-123");
|
||||||
|
mockPost.mockResolvedValue({});
|
||||||
|
const { collectAndSendHeartbeat } =
|
||||||
|
await import("../../utils/analytics.js");
|
||||||
|
|
||||||
|
await collectAndSendHeartbeat();
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, body] = mockPost.mock.calls[0];
|
||||||
|
expect(url).toContain("/capture/");
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
api_key: "phc_test",
|
||||||
|
event: "instance_heartbeat",
|
||||||
|
distinct_id: "instance-123",
|
||||||
|
properties: expect.objectContaining({
|
||||||
|
user_count: 0,
|
||||||
|
host_count: 0,
|
||||||
|
used_terminal: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import axios from "axios";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { users, hosts, recentActivity } from "../database/db/schema.js";
|
||||||
|
import {
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
createCurrentRepositoryContext,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import { Logger } from "./logger.js";
|
||||||
|
|
||||||
|
export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
|
||||||
|
|
||||||
|
const FEATURE_ACTIVITY_TYPES = [
|
||||||
|
"terminal",
|
||||||
|
"file_manager",
|
||||||
|
"tunnel",
|
||||||
|
"docker",
|
||||||
|
"telnet",
|
||||||
|
"vnc",
|
||||||
|
"rdp",
|
||||||
|
"server_stats",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||||
|
return createCurrentSettingsRepository().getBoolean(
|
||||||
|
"analytics_enabled",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateInstanceId(): Promise<string> {
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
const existing = await settings.get("analytics_instance_id");
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await settings.set("analytics_instance_id", id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppVersion(): string {
|
||||||
|
return process.env.VERSION || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectFeatureUsage(): Promise<Record<string, number>> {
|
||||||
|
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
|
||||||
|
const db = createCurrentRepositoryContext().drizzle;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
type: recentActivity.type,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(recentActivity)
|
||||||
|
.where(sql`${recentActivity.timestamp} >= ${since}`)
|
||||||
|
.groupBy(recentActivity.type);
|
||||||
|
|
||||||
|
const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
|
||||||
|
const usage: Record<string, number> = {};
|
||||||
|
for (const type of FEATURE_ACTIVITY_TYPES) {
|
||||||
|
usage[`used_${type}`] = counts.get(type) ?? 0;
|
||||||
|
}
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCounts(): Promise<{
|
||||||
|
userCount: number;
|
||||||
|
hostCount: number;
|
||||||
|
}> {
|
||||||
|
const db = createCurrentRepositoryContext().drizzle;
|
||||||
|
|
||||||
|
const [userRows, hostRows] = await Promise.all([
|
||||||
|
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||||
|
db.select({ count: sql<number>`count(*)` }).from(hosts),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCount: Number(userRows[0]?.count ?? 0),
|
||||||
|
hostCount: Number(hostRows[0]?.count ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectAndSendHeartbeat(): Promise<void> {
|
||||||
|
const apiKey = process.env.POSTHOG_API_KEY;
|
||||||
|
if (!apiKey) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await isAnalyticsEnabled())) return;
|
||||||
|
|
||||||
|
const instanceId = await getOrCreateInstanceId();
|
||||||
|
const { userCount, hostCount } = await collectCounts();
|
||||||
|
const featureUsage = await collectFeatureUsage();
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`${POSTHOG_HOST}/capture/`,
|
||||||
|
{
|
||||||
|
api_key: apiKey,
|
||||||
|
event: "instance_heartbeat",
|
||||||
|
distinct_id: instanceId,
|
||||||
|
properties: {
|
||||||
|
version: getAppVersion(),
|
||||||
|
user_count: userCount,
|
||||||
|
host_count: hostCount,
|
||||||
|
...featureUsage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
analyticsLogger.info("Sent daily usage heartbeat", {
|
||||||
|
operation: "analytics_heartbeat_sent",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
analyticsLogger.warn("Failed to send usage heartbeat", {
|
||||||
|
operation: "analytics_heartbeat_failed",
|
||||||
|
error: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAnalyticsHeartbeat(): void {
|
||||||
|
if (!process.env.POSTHOG_API_KEY) {
|
||||||
|
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
|
||||||
|
operation: "analytics_disabled_no_key",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void collectAndSendHeartbeat();
|
||||||
|
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
+53
-14
@@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=<linkToken>).
|
||||||
|
// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
|
||||||
|
const SharedSessionView = lazy(
|
||||||
|
() => import("@/features/session-sharing/SharedSessionView"),
|
||||||
|
);
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| "verifying"
|
| "verifying"
|
||||||
| "idle-auth"
|
| "idle-auth"
|
||||||
@@ -174,6 +180,7 @@ function App() {
|
|||||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||||
);
|
);
|
||||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||||
|
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Track whether fading-in came from a fresh login (vs. session verification on page load).
|
// Track whether fading-in came from a fresh login (vs. session verification on page load).
|
||||||
// When session-verified, Auth must not mount during the transition — it would trigger
|
// When session-verified, Auth must not mount during the transition — it would trigger
|
||||||
@@ -213,11 +220,36 @@ function App() {
|
|||||||
setPhase("fading-in");
|
setPhase("fading-in");
|
||||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: unknown) => {
|
||||||
clearStoredAuth();
|
// Only treat a genuine auth rejection (401/403) as "not logged in".
|
||||||
setPhase("idle-auth");
|
// Anything else (network hiccup, backend still starting up, a
|
||||||
|
// transient 5xx) is not proof the session is invalid -- clearing
|
||||||
|
// stored auth here would drop the user back to Auth.tsx, which in
|
||||||
|
// Electron immediately mints a brand-new auto-session, silently
|
||||||
|
// swapping out the JWT/cookie from under any still-in-flight
|
||||||
|
// requests and causing spurious "Session expired" toasts.
|
||||||
|
const status =
|
||||||
|
(err as { status?: number; response?: { status?: number } })
|
||||||
|
?.status ??
|
||||||
|
(err as { response?: { status?: number } })?.response?.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
clearStoredAuth();
|
||||||
|
setPhase("idle-auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Transient failure: retry shortly rather than logging out. Cap
|
||||||
|
// retries so a genuinely broken backend still surfaces the login
|
||||||
|
// screen eventually instead of spinning forever.
|
||||||
|
if (verifyRetryCount >= 5) {
|
||||||
|
clearStoredAuth();
|
||||||
|
setPhase("idle-auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setVerifyRetryCount((c) => c + 1);
|
||||||
|
}, 3000);
|
||||||
});
|
});
|
||||||
}, [phase]);
|
}, [phase, verifyRetryCount]);
|
||||||
|
|
||||||
function handleLogin(u: string) {
|
function handleLogin(u: string) {
|
||||||
setAuthUsername(u);
|
setAuthUsername(u);
|
||||||
@@ -226,6 +258,12 @@ function App() {
|
|||||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
||||||
|
const localJwt = localStorage.getItem("jwt");
|
||||||
|
if (localJwt) {
|
||||||
|
window.electronAPI
|
||||||
|
?.invoke?.("notify-local-login", localJwt)
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,11 +276,6 @@ function App() {
|
|||||||
}, 450);
|
}, 450);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChangeServer() {
|
|
||||||
localStorage.setItem("termix_show_server_config", "true");
|
|
||||||
handleLogout();
|
|
||||||
}
|
|
||||||
|
|
||||||
const showApp =
|
const showApp =
|
||||||
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
||||||
const showAuth =
|
const showAuth =
|
||||||
@@ -288,11 +321,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AppShell
|
<AppShell username={authUsername} onLogout={handleLogout} />
|
||||||
username={authUsername}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
onChangeServer={handleChangeServer}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -322,6 +351,16 @@ function RootApp() {
|
|||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const isFullscreen = searchParams.has("view");
|
const isFullscreen = searchParams.has("view");
|
||||||
|
|
||||||
|
// Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's
|
||||||
|
// auth check entirely rather than waiting on a getUserInfo() call that would always fail.
|
||||||
|
if (searchParams.get("view") === "shared") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SharedSessionView />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
Vendored
+9
-1
@@ -64,6 +64,15 @@ export interface ElectronAPI {
|
|||||||
started: number;
|
started: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}>;
|
}>;
|
||||||
|
onRemoteSyncStatusChanged?: (
|
||||||
|
callback: (status: {
|
||||||
|
connected: boolean;
|
||||||
|
syncing: boolean;
|
||||||
|
lastSyncedAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
needsReauth: boolean;
|
||||||
|
}) => void,
|
||||||
|
) => () => void;
|
||||||
clearSessionCookies: () => Promise<void>;
|
clearSessionCookies: () => Promise<void>;
|
||||||
getSessionCookie: (
|
getSessionCookie: (
|
||||||
name: string,
|
name: string,
|
||||||
@@ -157,7 +166,6 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electronAPI: ElectronAPI;
|
electronAPI: ElectronAPI;
|
||||||
IS_ELECTRON: boolean;
|
IS_ELECTRON: boolean;
|
||||||
configuredServerUrl?: string | null;
|
|
||||||
electronClipboard?: {
|
electronClipboard?: {
|
||||||
writeText(text: string): Promise<boolean>;
|
writeText(text: string): Promise<boolean>;
|
||||||
readText(): Promise<string>;
|
readText(): Promise<string>;
|
||||||
|
|||||||
Vendored
+33
@@ -97,6 +97,39 @@ declare module "guacamole-common-js" {
|
|||||||
up: boolean;
|
up: boolean;
|
||||||
down: boolean;
|
down: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MouseEvent {
|
||||||
|
state: Mouse.State;
|
||||||
|
preventDefault(): void;
|
||||||
|
stopPropagation(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Touchpad {
|
||||||
|
constructor(element: HTMLElement);
|
||||||
|
currentState: Mouse.State;
|
||||||
|
clickTimingThreshold: number;
|
||||||
|
clickMoveThreshold: number;
|
||||||
|
scrollThreshold: number;
|
||||||
|
onEach(
|
||||||
|
types: string[],
|
||||||
|
listener: (event: Mouse.MouseEvent) => void,
|
||||||
|
): void;
|
||||||
|
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Touchscreen {
|
||||||
|
constructor(element: HTMLElement);
|
||||||
|
currentState: Mouse.State;
|
||||||
|
clickTimingThreshold: number;
|
||||||
|
clickMoveThreshold: number;
|
||||||
|
scrollThreshold: number;
|
||||||
|
longPressThreshold: number;
|
||||||
|
onEach(
|
||||||
|
types: string[],
|
||||||
|
listener: (event: Mouse.MouseEvent) => void,
|
||||||
|
): void;
|
||||||
|
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Keyboard {
|
class Keyboard {
|
||||||
|
|||||||
+5
-2
@@ -151,6 +151,7 @@ export interface Host {
|
|||||||
enableDocker: boolean;
|
enableDocker: boolean;
|
||||||
enableProxmox: boolean;
|
enableProxmox: boolean;
|
||||||
enableTmuxMonitor: boolean;
|
enableTmuxMonitor: boolean;
|
||||||
|
allowSessionSharing?: boolean;
|
||||||
proxmoxConfig?: ProxmoxConfig | null;
|
proxmoxConfig?: ProxmoxConfig | null;
|
||||||
showTerminalInSidebar: boolean;
|
showTerminalInSidebar: boolean;
|
||||||
showFileManagerInSidebar: boolean;
|
showFileManagerInSidebar: boolean;
|
||||||
@@ -207,7 +208,7 @@ export interface Host {
|
|||||||
telnetUser?: string;
|
telnetUser?: string;
|
||||||
telnetPassword?: string;
|
telnetPassword?: string;
|
||||||
telnetCredentialId?: number | null;
|
telnetCredentialId?: number | null;
|
||||||
rdpAuthType?: "direct" | "credential" | null;
|
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||||
vncAuthType?: "direct" | "credential" | null;
|
vncAuthType?: "direct" | "credential" | null;
|
||||||
telnetAuthType?: "direct" | "credential" | null;
|
telnetAuthType?: "direct" | "credential" | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -272,6 +273,7 @@ export interface HostData {
|
|||||||
enableDocker?: boolean;
|
enableDocker?: boolean;
|
||||||
enableProxmox?: boolean;
|
enableProxmox?: boolean;
|
||||||
enableTmuxMonitor?: boolean;
|
enableTmuxMonitor?: boolean;
|
||||||
|
allowSessionSharing?: boolean;
|
||||||
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
||||||
showTerminalInSidebar?: boolean;
|
showTerminalInSidebar?: boolean;
|
||||||
showFileManagerInSidebar?: boolean;
|
showFileManagerInSidebar?: boolean;
|
||||||
@@ -329,7 +331,7 @@ export interface HostData {
|
|||||||
telnetUser?: string;
|
telnetUser?: string;
|
||||||
telnetPassword?: string;
|
telnetPassword?: string;
|
||||||
telnetCredentialId?: number | null;
|
telnetCredentialId?: number | null;
|
||||||
rdpAuthType?: "direct" | "credential" | null;
|
rdpAuthType?: "direct" | "credential" | "none" | null;
|
||||||
vncAuthType?: "direct" | "credential" | null;
|
vncAuthType?: "direct" | "credential" | null;
|
||||||
telnetAuthType?: "direct" | "credential" | null;
|
telnetAuthType?: "direct" | "credential" | null;
|
||||||
}
|
}
|
||||||
@@ -343,6 +345,7 @@ export interface SSHFolder {
|
|||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type Host = {
|
|||||||
useSocks5?: boolean;
|
useSocks5?: boolean;
|
||||||
socks5Host?: string;
|
socks5Host?: string;
|
||||||
socks5Port?: number;
|
socks5Port?: number;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
socks5Username?: string;
|
socks5Username?: string;
|
||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: {
|
socks5ProxyChain?: {
|
||||||
@@ -152,6 +153,7 @@ export type Host = {
|
|||||||
vncPort: number;
|
vncPort: number;
|
||||||
telnetPort: number;
|
telnetPort: number;
|
||||||
|
|
||||||
|
rdpAuthType?: "direct" | "credential" | "none";
|
||||||
rdpCredentialId?: string;
|
rdpCredentialId?: string;
|
||||||
rdpUser?: string;
|
rdpUser?: string;
|
||||||
rdpPassword?: string;
|
rdpPassword?: string;
|
||||||
@@ -159,10 +161,13 @@ export type Host = {
|
|||||||
security?: string;
|
security?: string;
|
||||||
ignoreCert?: boolean;
|
ignoreCert?: boolean;
|
||||||
|
|
||||||
|
vncAuthType?: "direct" | "credential";
|
||||||
vncCredentialId?: string;
|
vncCredentialId?: string;
|
||||||
vncPassword?: string;
|
vncPassword?: string;
|
||||||
vncUser?: string;
|
vncUser?: string;
|
||||||
|
|
||||||
|
telnetAuthType?: "direct" | "credential";
|
||||||
|
telnetCredentialId?: string;
|
||||||
telnetUser?: string;
|
telnetUser?: string;
|
||||||
telnetPassword?: string;
|
telnetPassword?: string;
|
||||||
|
|
||||||
@@ -217,6 +222,7 @@ export type HostFolder = {
|
|||||||
path?: string;
|
path?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabType =
|
export type TabType =
|
||||||
@@ -276,6 +282,9 @@ export type Tab = {
|
|||||||
host?: Host;
|
host?: Host;
|
||||||
openedAt: number;
|
openedAt: number;
|
||||||
restoredSessionId?: string | null;
|
restoredSessionId?: string | null;
|
||||||
|
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
|
||||||
|
joinSharedSessionId?: string | null;
|
||||||
|
joinShareId?: string | null;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
serialConfig?: SerialConfig;
|
serialConfig?: SerialConfig;
|
||||||
terminalRef?: import("react").RefObject<{
|
terminalRef?: import("react").RefObject<{
|
||||||
@@ -286,6 +295,8 @@ export type Tab = {
|
|||||||
fit?: () => void;
|
fit?: () => void;
|
||||||
notifyResize?: () => void;
|
notifyResize?: () => void;
|
||||||
getApplicationCursorKeysMode?: () => boolean;
|
getApplicationCursorKeysMode?: () => boolean;
|
||||||
|
openShareModal?: () => void;
|
||||||
|
canShare?: () => boolean;
|
||||||
} | null>;
|
} | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+315
-179
@@ -126,10 +126,12 @@ import {
|
|||||||
getActiveSessions,
|
getActiveSessions,
|
||||||
getUserPreferences,
|
getUserPreferences,
|
||||||
dismissDonationModal,
|
dismissDonationModal,
|
||||||
|
isElectron,
|
||||||
type UserPreferences,
|
type UserPreferences,
|
||||||
type OpenTabRecord,
|
type OpenTabRecord,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||||
|
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
import type { SSHHostWithStatus } from "@/main-axios";
|
||||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||||
@@ -141,7 +143,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
|||||||
|
|
||||||
function buildHostTree(
|
function buildHostTree(
|
||||||
hosts: SSHHostWithStatus[],
|
hosts: SSHHostWithStatus[],
|
||||||
folderMeta?: Map<string, { color?: string; icon?: string }>,
|
folderMeta?: Map<
|
||||||
|
string,
|
||||||
|
{ color?: string; icon?: string; credentialId?: number | null }
|
||||||
|
>,
|
||||||
): HostFolder {
|
): HostFolder {
|
||||||
const root: HostFolder = { name: "root", children: [] };
|
const root: HostFolder = { name: "root", children: [] };
|
||||||
const folderMap = new Map<string, HostFolder>();
|
const folderMap = new Map<string, HostFolder>();
|
||||||
@@ -159,6 +164,7 @@ function buildHostTree(
|
|||||||
path: accumulated,
|
path: accumulated,
|
||||||
color: meta?.color,
|
color: meta?.color,
|
||||||
icon: meta?.icon,
|
icon: meta?.icon,
|
||||||
|
credentialId: meta?.credentialId ?? null,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
folderMap.set(accumulated, folder);
|
folderMap.set(accumulated, folder);
|
||||||
@@ -189,11 +195,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
username,
|
username,
|
||||||
onLogout,
|
onLogout,
|
||||||
onChangeServer,
|
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onChangeServer?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
@@ -218,11 +222,14 @@ export function AppShell({
|
|||||||
const [splitMode, setSplitMode] = useState<SplitMode>(
|
const [splitMode, setSplitMode] = useState<SplitMode>(
|
||||||
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
|
||||||
);
|
);
|
||||||
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
|
// paneTabIds holds live tab.id values, which change on every restore, so we
|
||||||
() =>
|
// can't restore it from storage directly. It starts empty and gets filled in
|
||||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
// once by the reconciliation effect below, keyed off the stable instanceId
|
||||||
Array(6).fill(null),
|
// values saved in termix_paneInstanceIds.
|
||||||
|
const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
|
||||||
|
Array(6).fill(null),
|
||||||
);
|
);
|
||||||
|
const paneLayoutRestoredRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
paneTabIdsRef.current = paneTabIds;
|
paneTabIdsRef.current = paneTabIds;
|
||||||
}, [paneTabIds]);
|
}, [paneTabIds]);
|
||||||
@@ -231,6 +238,13 @@ export function AppShell({
|
|||||||
const [hostsLoading, setHostsLoading] = useState(true);
|
const [hostsLoading, setHostsLoading] = useState(true);
|
||||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
// Remote sync is not yet configurable (added in a later phase), so this
|
||||||
|
// is always false for now -- admin/user-management UI stays hidden until
|
||||||
|
// the desktop app is connected to a remote Termix server, since a
|
||||||
|
// standalone local install has exactly one implicit user and nothing to
|
||||||
|
// administer.
|
||||||
|
const [isRemoteSyncConnected] = useState(false);
|
||||||
|
const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected);
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||||
@@ -258,8 +272,15 @@ export function AppShell({
|
|||||||
}, [splitMode]);
|
}, [splitMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
|
// Don't overwrite the saved layout with the empty initial state before
|
||||||
}, [paneTabIds]);
|
// reconciliation has had a chance to restore it.
|
||||||
|
if (!paneLayoutRestoredRef.current) return;
|
||||||
|
const instanceIds = paneTabIds.map((id) => {
|
||||||
|
if (id == null) return null;
|
||||||
|
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||||
|
});
|
||||||
|
localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
|
||||||
|
}, [paneTabIds, tabs]);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -798,11 +819,15 @@ export function AppShell({
|
|||||||
]);
|
]);
|
||||||
const converted = raw.map(sshHostToHost);
|
const converted = raw.map(sshHostToHost);
|
||||||
setAllHosts(converted);
|
setAllHosts(converted);
|
||||||
const folderMeta = new Map<string, { color?: string; icon?: string }>();
|
const folderMeta = new Map<
|
||||||
|
string,
|
||||||
|
{ color?: string; icon?: string; credentialId?: number | null }
|
||||||
|
>();
|
||||||
for (const f of folders) {
|
for (const f of folders) {
|
||||||
folderMeta.set(f.name, {
|
folderMeta.set(f.name, {
|
||||||
color: f.color ?? undefined,
|
color: f.color ?? undefined,
|
||||||
icon: f.icon ?? undefined,
|
icon: f.icon ?? undefined,
|
||||||
|
credentialId: f.credentialId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setRealHostTree(buildHostTree(raw, folderMeta));
|
setRealHostTree(buildHostTree(raw, folderMeta));
|
||||||
@@ -968,6 +993,35 @@ export function AppShell({
|
|||||||
loadSavedTabs();
|
loadSavedTabs();
|
||||||
}, [hostsLoaded, userPrefsLoaded]);
|
}, [hostsLoaded, userPrefsLoaded]);
|
||||||
|
|
||||||
|
// Restore split-screen pane assignments once tabs are settled. Saved assignments are
|
||||||
|
// keyed by instanceId (stable across reloads) and remapped to the live tab.id here,
|
||||||
|
// since tab.id is regenerated every time a tab is (re)opened.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||||
|
paneLayoutRestoredRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedInstanceIds: (string | null)[] = JSON.parse(
|
||||||
|
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||||
|
);
|
||||||
|
if (!Array.isArray(savedInstanceIds)) return;
|
||||||
|
|
||||||
|
const restored = savedInstanceIds.map((instanceId) => {
|
||||||
|
if (instanceId == null) return null;
|
||||||
|
return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
|
||||||
|
});
|
||||||
|
if (restored.some((id) => id != null)) {
|
||||||
|
setPaneTabIds(restored);
|
||||||
|
} else {
|
||||||
|
// None of the saved panes could be restored (e.g. reopen-tabs-on-login
|
||||||
|
// is disabled), so drop back to a single view instead of an empty split.
|
||||||
|
setSplitMode("none");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}, [tabsReady, tabs]);
|
||||||
|
|
||||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
@@ -1006,6 +1060,8 @@ export function AppShell({
|
|||||||
savedLabel?: string;
|
savedLabel?: string;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
serialConfig?: SerialConfig;
|
serialConfig?: SerialConfig;
|
||||||
|
joinSharedSessionId?: string | null;
|
||||||
|
joinShareId?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||||
@@ -1022,6 +1078,8 @@ export function AppShell({
|
|||||||
const savedLabel = restore?.savedLabel;
|
const savedLabel = restore?.savedLabel;
|
||||||
const initialFilePath = restore?.initialFilePath;
|
const initialFilePath = restore?.initialFilePath;
|
||||||
const serialConfig = restore?.serialConfig;
|
const serialConfig = restore?.serialConfig;
|
||||||
|
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
||||||
|
const joinShareId = restore?.joinShareId ?? null;
|
||||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||||
const isCustomLabel =
|
const isCustomLabel =
|
||||||
savedLabel != null &&
|
savedLabel != null &&
|
||||||
@@ -1043,6 +1101,8 @@ export function AppShell({
|
|||||||
openedAt,
|
openedAt,
|
||||||
terminalRef: ref,
|
terminalRef: ref,
|
||||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
joinSharedSessionId,
|
||||||
|
joinShareId,
|
||||||
initialFilePath,
|
initialFilePath,
|
||||||
serialConfig,
|
serialConfig,
|
||||||
},
|
},
|
||||||
@@ -1075,6 +1135,8 @@ export function AppShell({
|
|||||||
openedAt,
|
openedAt,
|
||||||
terminalRef: ref,
|
terminalRef: ref,
|
||||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
joinSharedSessionId,
|
||||||
|
joinShareId,
|
||||||
initialFilePath,
|
initialFilePath,
|
||||||
serialConfig,
|
serialConfig,
|
||||||
},
|
},
|
||||||
@@ -1330,6 +1392,17 @@ export function AppShell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openShareForTab(id: string) {
|
||||||
|
const tab = tabs.find((t) => t.id === id);
|
||||||
|
if (!tab) return;
|
||||||
|
const ref = tab.terminalRef?.current;
|
||||||
|
if (ref?.canShare?.()) {
|
||||||
|
ref.openShareModal?.();
|
||||||
|
} else {
|
||||||
|
toast.error(t("sessionSharing.notReadyToShare"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeTab(id: string) {
|
function closeTab(id: string) {
|
||||||
const tab = tabs.find((t) => t.id === id);
|
const tab = tabs.find((t) => t.id === id);
|
||||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||||
@@ -1675,6 +1748,56 @@ export function AppShell({
|
|||||||
}}
|
}}
|
||||||
onRenameTab={renameTab}
|
onRenameTab={renameTab}
|
||||||
onReorderTabs={setTabs}
|
onReorderTabs={setTabs}
|
||||||
|
onJoinSharedSession={(session) => {
|
||||||
|
if (!session.shareId) return;
|
||||||
|
const existingHost = allHosts.find(
|
||||||
|
(h) => h.id === String(session.hostId),
|
||||||
|
);
|
||||||
|
const host: Host = existingHost ?? {
|
||||||
|
id: String(session.hostId),
|
||||||
|
name: session.hostName,
|
||||||
|
username: "",
|
||||||
|
ip: "",
|
||||||
|
port: 0,
|
||||||
|
folder: "",
|
||||||
|
online: false,
|
||||||
|
cpu: null,
|
||||||
|
ram: null,
|
||||||
|
lastAccess: new Date().toISOString(),
|
||||||
|
authType: "none",
|
||||||
|
enableTerminal: false,
|
||||||
|
enableCommandHistory: false,
|
||||||
|
enableTunnel: false,
|
||||||
|
enableFileManager: false,
|
||||||
|
enableDocker: false,
|
||||||
|
enableProxmox: false,
|
||||||
|
enableTmuxMonitor: false,
|
||||||
|
enableSsh: false,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
sshPort: 22,
|
||||||
|
rdpPort: 3389,
|
||||||
|
vncPort: 5900,
|
||||||
|
telnetPort: 23,
|
||||||
|
serverTunnels: [],
|
||||||
|
quickActions: [],
|
||||||
|
};
|
||||||
|
const instanceId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
openTab(host, "terminal", {
|
||||||
|
instanceId,
|
||||||
|
restoredSessionId: null,
|
||||||
|
joinSharedSessionId: session.sessionId,
|
||||||
|
joinShareId: session.shareId,
|
||||||
|
savedLabel: t("connections.sharedSessionLabel", {
|
||||||
|
hostName: session.hostName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1690,7 +1813,6 @@ export function AppShell({
|
|||||||
<UserProfilePanel
|
<UserProfilePanel
|
||||||
username={username}
|
username={username}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onChangeServer={onChangeServer}
|
|
||||||
userPrefs={userPrefs}
|
userPrefs={userPrefs}
|
||||||
onPrefsChange={(updates) =>
|
onPrefsChange={(updates) =>
|
||||||
setUserPrefs((current) => ({ ...current, ...updates }))
|
setUserPrefs((current) => ({ ...current, ...updates }))
|
||||||
@@ -1699,7 +1821,7 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{railView === "admin-settings" && isAdmin && (
|
{railView === "admin-settings" && showMultiUserUI && (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
<AdminSettingsPanel
|
<AdminSettingsPanel
|
||||||
onEditingChange={setSidebarEditing}
|
onEditingChange={setSidebarEditing}
|
||||||
@@ -1754,180 +1876,194 @@ export function AppShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerStatusProvider isAuthenticated={!!username}>
|
<ServerStatusProvider isAuthenticated={!!username}>
|
||||||
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
|
<div
|
||||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
className="flex flex-col w-screen bg-background"
|
||||||
<AppRail
|
style={{ height: "100dvh" }}
|
||||||
railView={railView}
|
>
|
||||||
sidebarOpen={sidebarOpen}
|
{isElectron() && (
|
||||||
splitMode={splitMode}
|
<RemoteSyncBanner
|
||||||
username={username}
|
onReconnect={() => {
|
||||||
isAdmin={isAdmin}
|
setRailView("user-profile");
|
||||||
onRailClick={handleRailClick}
|
if (!sidebarOpen) setSidebarOpen(true);
|
||||||
onOpenTab={openSingletonTab}
|
|
||||||
onLogout={onLogout}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Desktop: inline resizable sidebar */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div
|
|
||||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
|
||||||
style={{
|
|
||||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
|
||||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{sidebarHeader}
|
|
||||||
{sidebarPanelContent}
|
|
||||||
|
|
||||||
{sidebarOpen && !sidebarEditing && (
|
|
||||||
<div
|
|
||||||
onMouseDown={onSidebarMouseDown}
|
|
||||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* Mobile: sidebar as overlay sheet */}
|
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||||
{isMobile && (
|
<AppRail
|
||||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
||||||
<SheetContent
|
|
||||||
side="left"
|
|
||||||
showCloseButton={false}
|
|
||||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
|
||||||
style={{ height: "100dvh" }}
|
|
||||||
>
|
|
||||||
{sidebarHeader}
|
|
||||||
{sidebarPanelContent}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div
|
|
||||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
|
||||||
>
|
|
||||||
{!isMobile && !sidebarOpen && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
title="Open Sidebar"
|
|
||||||
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
|
||||||
<TabBar
|
|
||||||
tabs={tabs}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
splitMode={splitMode}
|
|
||||||
paneTabIds={paneTabIds}
|
|
||||||
focusedPaneIndex={focusedPaneIndex}
|
|
||||||
onSetActiveTab={setActiveTabId}
|
|
||||||
onCloseTab={closeTab}
|
|
||||||
onRefreshTab={refreshTab}
|
|
||||||
onReorderTabs={setTabs}
|
|
||||||
onSplitTab={splitTabQuick}
|
|
||||||
onAddToSplit={addTabToSplit}
|
|
||||||
onRemoveFromSplit={removeTabFromSplit}
|
|
||||||
onRenameTab={renameTab}
|
|
||||||
onOpenFileManager={(tabId) => {
|
|
||||||
const targetTab = tabs.find((t) => t.id === tabId);
|
|
||||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
|
||||||
}}
|
|
||||||
isAppFullscreen={isAppFullscreen}
|
|
||||||
onToggleAppFullscreen={toggleAppFullscreen}
|
|
||||||
/>
|
|
||||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
||||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
|
||||||
{!isMobile && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
display: isSplit ? "flex" : "none",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SplitView
|
|
||||||
tabs={tabs}
|
|
||||||
paneTabIds={paneTabIds}
|
|
||||||
splitMode={splitMode}
|
|
||||||
focusedPaneIndex={focusedPaneIndex}
|
|
||||||
onTerminalResize={resizeAllTerminals}
|
|
||||||
onPaneContentRef={onPaneContentRef}
|
|
||||||
onPaneClick={setFocusedPaneIndex}
|
|
||||||
onAssignPane={assignPane}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
|
||||||
by the DOM-placement effect above. React portals each tab's content
|
|
||||||
into its stable per-tab node so the component is never remounted.
|
|
||||||
When split is active, shown on top only if the active tab is not in a pane. */}
|
|
||||||
<div
|
|
||||||
ref={normalViewRef}
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
display:
|
|
||||||
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
|
||||||
? "none"
|
|
||||||
: undefined,
|
|
||||||
zIndex:
|
|
||||||
isSplit && !paneTabIds.includes(activeTabId)
|
|
||||||
? 10
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
|
||||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
|
||||||
const inPane = paneIdx !== -1;
|
|
||||||
const activeInline = !inPane && tab.id === activeTabId;
|
|
||||||
return createPortal(
|
|
||||||
renderTabContent(
|
|
||||||
tab,
|
|
||||||
openSingletonTab,
|
|
||||||
openTab,
|
|
||||||
closeTab,
|
|
||||||
inPane || activeInline,
|
|
||||||
(host, filePath) =>
|
|
||||||
openTab(host, "files", {
|
|
||||||
instanceId:
|
|
||||||
typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
restoredSessionId: null,
|
|
||||||
initialFilePath: filePath,
|
|
||||||
}),
|
|
||||||
(host, _path) => openTab(host, "files"),
|
|
||||||
(host, path) =>
|
|
||||||
openTab(host, "terminal", {
|
|
||||||
instanceId:
|
|
||||||
typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
restoredSessionId: null,
|
|
||||||
initialFilePath: path,
|
|
||||||
}),
|
|
||||||
renameTab,
|
|
||||||
saveQuickConnectHost,
|
|
||||||
),
|
|
||||||
tabNode,
|
|
||||||
tab.id,
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom nav bar — mobile only */}
|
|
||||||
<MobileBottomBar
|
|
||||||
railView={railView}
|
railView={railView}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
splitMode={splitMode}
|
splitMode={splitMode}
|
||||||
|
username={username}
|
||||||
|
isAdmin={showMultiUserUI}
|
||||||
onRailClick={handleRailClick}
|
onRailClick={handleRailClick}
|
||||||
|
onOpenTab={openSingletonTab}
|
||||||
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Desktop: inline resizable sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||||
|
style={{
|
||||||
|
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||||
|
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sidebarHeader}
|
||||||
|
{sidebarPanelContent}
|
||||||
|
|
||||||
|
{sidebarOpen && !sidebarEditing && (
|
||||||
|
<div
|
||||||
|
onMouseDown={onSidebarMouseDown}
|
||||||
|
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile: sidebar as overlay sheet */}
|
||||||
|
{isMobile && (
|
||||||
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||||
|
style={{ height: "100dvh" }}
|
||||||
|
>
|
||||||
|
{sidebarHeader}
|
||||||
|
{sidebarPanelContent}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div
|
||||||
|
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||||
|
>
|
||||||
|
{!isMobile && !sidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
title="Open Sidebar"
|
||||||
|
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||||
|
<TabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
splitMode={splitMode}
|
||||||
|
paneTabIds={paneTabIds}
|
||||||
|
focusedPaneIndex={focusedPaneIndex}
|
||||||
|
onSetActiveTab={setActiveTabId}
|
||||||
|
onCloseTab={closeTab}
|
||||||
|
onRefreshTab={refreshTab}
|
||||||
|
onReorderTabs={setTabs}
|
||||||
|
onSplitTab={splitTabQuick}
|
||||||
|
onAddToSplit={addTabToSplit}
|
||||||
|
onRemoveFromSplit={removeTabFromSplit}
|
||||||
|
onRenameTab={renameTab}
|
||||||
|
onOpenFileManager={(tabId) => {
|
||||||
|
const targetTab = tabs.find((t) => t.id === tabId);
|
||||||
|
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||||
|
}}
|
||||||
|
onOpenShare={openShareForTab}
|
||||||
|
isAppFullscreen={isAppFullscreen}
|
||||||
|
onToggleAppFullscreen={toggleAppFullscreen}
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
|
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||||
|
{!isMobile && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
display: isSplit ? "flex" : "none",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SplitView
|
||||||
|
tabs={tabs}
|
||||||
|
paneTabIds={paneTabIds}
|
||||||
|
splitMode={splitMode}
|
||||||
|
focusedPaneIndex={focusedPaneIndex}
|
||||||
|
onTerminalResize={resizeAllTerminals}
|
||||||
|
onPaneContentRef={onPaneContentRef}
|
||||||
|
onPaneClick={setFocusedPaneIndex}
|
||||||
|
onAssignPane={assignPane}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||||
|
by the DOM-placement effect above. React portals each tab's content
|
||||||
|
into its stable per-tab node so the component is never remounted.
|
||||||
|
When split is active, shown on top only if the active tab is not in a pane. */}
|
||||||
|
<div
|
||||||
|
ref={normalViewRef}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
zIndex:
|
||||||
|
isSplit && !paneTabIds.includes(activeTabId)
|
||||||
|
? 10
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||||
|
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||||
|
const inPane = paneIdx !== -1;
|
||||||
|
const activeInline = !inPane && tab.id === activeTabId;
|
||||||
|
return createPortal(
|
||||||
|
renderTabContent(
|
||||||
|
tab,
|
||||||
|
openSingletonTab,
|
||||||
|
openTab,
|
||||||
|
closeTab,
|
||||||
|
inPane || activeInline,
|
||||||
|
(host, filePath) =>
|
||||||
|
openTab(host, "files", {
|
||||||
|
instanceId:
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
restoredSessionId: null,
|
||||||
|
initialFilePath: filePath,
|
||||||
|
}),
|
||||||
|
(host, _path) => openTab(host, "files"),
|
||||||
|
(host, path) =>
|
||||||
|
openTab(host, "terminal", {
|
||||||
|
instanceId:
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
restoredSessionId: null,
|
||||||
|
initialFilePath: path,
|
||||||
|
}),
|
||||||
|
renameTab,
|
||||||
|
saveQuickConnectHost,
|
||||||
|
),
|
||||||
|
tabNode,
|
||||||
|
tab.id,
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom nav bar — mobile only */}
|
||||||
|
<MobileBottomBar
|
||||||
|
railView={railView}
|
||||||
|
sidebarOpen={sidebarOpen}
|
||||||
|
splitMode={splitMode}
|
||||||
|
onRailClick={handleRailClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { authApi, handleApiError } from "@/main-axios";
|
import { authApi, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
|
||||||
|
|
||||||
export type AcmeSettings = {
|
export type AcmeSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
|
|||||||
handleApiError(error, "request ACME certificate");
|
handleApiError(error, "request ACME certificate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadManualSslCertificate(payload: {
|
||||||
|
certificate: string;
|
||||||
|
privateKey: string;
|
||||||
|
}): Promise<AcmeSettings & { success: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/users/manual-ssl-upload", payload);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "upload manual SSL certificate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,7 +99,12 @@ export async function adminDeleteUserHost(
|
|||||||
export async function adminGetHostPassword(
|
export async function adminGetHostPassword(
|
||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
field:
|
||||||
|
| "password"
|
||||||
|
| "sudoPassword"
|
||||||
|
| "vncPassword"
|
||||||
|
| "key"
|
||||||
|
| "keyPassword" = "password",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(
|
const response = await sshHostApi.get(
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ export async function getSSHHostWithCredentials(
|
|||||||
|
|
||||||
export async function getHostPassword(
|
export async function getHostPassword(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
field: "password" | "sudoPassword" | "vncPassword" = "password",
|
field:
|
||||||
|
| "password"
|
||||||
|
| "sudoPassword"
|
||||||
|
| "vncPassword"
|
||||||
|
| "key"
|
||||||
|
| "keyPassword" = "password",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.get(
|
const response = await sshHostApi.get(
|
||||||
@@ -200,6 +205,7 @@ export async function updateFolderMetadata(
|
|||||||
name: string,
|
name: string,
|
||||||
color?: string,
|
color?: string,
|
||||||
icon?: string,
|
icon?: string,
|
||||||
|
credentialId?: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
sshLogger.info("Updating folder metadata", {
|
sshLogger.info("Updating folder metadata", {
|
||||||
@@ -207,12 +213,14 @@ export async function updateFolderMetadata(
|
|||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
credentialId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await authApi.put("/host/folders/metadata", {
|
await authApi.put("/host/folders/metadata", {
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
|
credentialId,
|
||||||
});
|
});
|
||||||
|
|
||||||
sshLogger.success("Folder metadata updated successfully", {
|
sshLogger.success("Folder metadata updated successfully", {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
|
|||||||
|
|
||||||
export interface GuacamoleTokenResponse {
|
export interface GuacamoleTokenResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
guacamoleConnectionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GuacamoleConfigSource = {
|
type GuacamoleConfigSource = {
|
||||||
@@ -208,12 +209,18 @@ export async function getGuacamoleToken(
|
|||||||
export async function getGuacamoleTokenFromHost(
|
export async function getGuacamoleTokenFromHost(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
protocol?: "rdp" | "vnc" | "telnet",
|
protocol?: "rdp" | "vnc" | "telnet",
|
||||||
|
promptedCredentials?: { username?: string; password?: string },
|
||||||
): Promise<GuacamoleTokenResponse> {
|
): Promise<GuacamoleTokenResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(
|
const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
|
||||||
`/guacamole/connect-host/${hostId}`,
|
...(protocol ? { protocol } : {}),
|
||||||
protocol ? { protocol } : {},
|
...(promptedCredentials?.username
|
||||||
);
|
? { promptedUsername: promptedCredentials.username }
|
||||||
|
: {}),
|
||||||
|
...(promptedCredentials?.password
|
||||||
|
? { promptedPassword: promptedCredentials.password }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get guacamole token from host");
|
throw handleApiError(error, "get guacamole token from host");
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { handleApiError, statsApi } from "@/main-axios";
|
import { handleApiError, statsApi } from "@/main-axios";
|
||||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||||
|
|
||||||
|
// Every function below is keyed by a host's numeric database id, and the
|
||||||
|
// receiving backend must own that host in its own database -- a synced
|
||||||
|
// host has a different numeric id on each side (only its syncId matches
|
||||||
|
// across them). These calls always target the embedded local backend; see
|
||||||
|
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
|
||||||
|
// call that IS safely merged across local + remote (a process-local,
|
||||||
|
// in-memory aggregate keyed by whichever host ids that process happens to
|
||||||
|
// know about, not a per-host lookup).
|
||||||
|
|
||||||
export interface MetricsHistoryRow {
|
export interface MetricsHistoryRow {
|
||||||
ts: string;
|
ts: string;
|
||||||
cpu_percent: number | null;
|
cpu_percent: number | null;
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
import axios, { type AxiosRequestConfig } from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
import { handleApiError, statsApi } from "@/main-axios";
|
import {
|
||||||
|
handleApiError,
|
||||||
|
statsApi,
|
||||||
|
getRemoteStatsApi,
|
||||||
|
isElectron,
|
||||||
|
} from "@/main-axios";
|
||||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||||
|
|
||||||
|
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||||
|
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||||
|
// receives the call must own the target host by numeric database id, and a
|
||||||
|
// synced host has a different numeric id in each database (only its
|
||||||
|
// syncId matches across them). Only the aggregate status read is merged
|
||||||
|
// across local + remote, same as tunnel status.
|
||||||
|
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||||
|
if (!isElectron()) return false;
|
||||||
|
try {
|
||||||
|
const config = (await window.electronAPI?.invoke?.(
|
||||||
|
"get-remote-sync-config",
|
||||||
|
)) as { serverUrl?: string } | null;
|
||||||
|
return !!config?.serverUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ApiConnectionLog = {
|
type ApiConnectionLog = {
|
||||||
type: "info" | "success" | "warning" | "error";
|
type: "info" | "success" | "warning" | "error";
|
||||||
stage: string;
|
stage: string;
|
||||||
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
|
|||||||
> {
|
> {
|
||||||
return getCachedServerStatuses(async () => {
|
return getCachedServerStatuses(async () => {
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
let localStatuses: Record<number, ServerStatus> = {};
|
||||||
|
|
||||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||||
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
|
|||||||
// blips don't look like real outages.
|
// blips don't look like real outages.
|
||||||
__silentRetry: !isFinalAttempt,
|
__silentRetry: !isFinalAttempt,
|
||||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||||
return response.data || {};
|
localStatuses = response.data || {};
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (!isTransientStatusError(error)) {
|
if (!isTransientStatusError(error)) {
|
||||||
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleApiError(lastError, "fetch server statuses");
|
if (lastError) {
|
||||||
return {};
|
handleApiError(lastError, "fetch server statuses");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isRemoteSyncConnected()) {
|
||||||
|
try {
|
||||||
|
const remoteResult = await getRemoteStatsApi().get("/status", {
|
||||||
|
timeout: 8000,
|
||||||
|
__silentRetry: true,
|
||||||
|
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||||
|
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||||
|
} catch {
|
||||||
|
// remote unreachable this tick -- fall back to local-only statuses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStatuses;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { authApi } from "@/main-axios";
|
import { authApi } from "@/main-axios";
|
||||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||||
|
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||||
|
|
||||||
// OPEN TABS API
|
// OPEN TABS API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -41,6 +42,10 @@ export interface ActiveSessionInfo {
|
|||||||
tabInstanceId: string | null;
|
tabInstanceId: string | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
isOwnSession: boolean;
|
||||||
|
sharedByUsername: string | null;
|
||||||
|
permissionLevel: string | null;
|
||||||
|
shareId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||||
@@ -82,6 +87,12 @@ export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
|||||||
// USER PREFERENCES API
|
// USER PREFERENCES API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SavedCustomTheme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colors: TerminalTheme["colors"];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
reopenTabsOnLogin: boolean;
|
reopenTabsOnLogin: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
@@ -102,6 +113,17 @@ export interface UserPreferences {
|
|||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
compactHostView?: boolean | null;
|
compactHostView?: boolean | null;
|
||||||
statusColorScheme?: string | null;
|
statusColorScheme?: string | null;
|
||||||
|
customThemes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
|
|||||||
@@ -124,6 +124,31 @@ export async function shareHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shareFolder(
|
||||||
|
folder: string,
|
||||||
|
shareData: {
|
||||||
|
targets: ShareTarget[];
|
||||||
|
permissionLevel: SharePermissionLevel;
|
||||||
|
durationHours?: number;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
hostsShared: number;
|
||||||
|
hostsTotal: number;
|
||||||
|
hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await rbacApi.post("/rbac/folder/share", {
|
||||||
|
folder,
|
||||||
|
...shareData,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "share folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateHostAccess(
|
export async function updateHostAccess(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
accessId: number,
|
accessId: number,
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { getBasePath } from "@/lib/base-path";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { authApi, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
|
export interface ResolvedShareLink {
|
||||||
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
permissionLevel: "read-only" | "read-write";
|
||||||
|
wsPath: string;
|
||||||
|
connectParams?: { token: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||||
|
|
||||||
|
export class ShareLinkError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly kind: ShareLinkErrorKind,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ShareLinkError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = (): boolean =>
|
||||||
|
!isElectron() &&
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
(window.location.port === "3000" ||
|
||||||
|
window.location.port === "5173" ||
|
||||||
|
window.location.port === "");
|
||||||
|
|
||||||
|
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||||
|
// rather than going through main-axios's authenticated instances. The
|
||||||
|
// desktop app always runs its embedded local backend as the source of
|
||||||
|
// truth, so a share link opened there always resolves against it --
|
||||||
|
// joining a session hosted on someone else's remote server isn't
|
||||||
|
// supported from the desktop app today.
|
||||||
|
async function resolveApiBaseUrl(): Promise<string> {
|
||||||
|
if (isDev()) {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||||
|
return `${protocol}://localhost:30001`;
|
||||||
|
}
|
||||||
|
if (isElectron()) {
|
||||||
|
return "http://127.0.0.1:30001";
|
||||||
|
}
|
||||||
|
return getBasePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveShareLink(
|
||||||
|
linkToken: string,
|
||||||
|
): Promise<ResolvedShareLink> {
|
||||||
|
const baseUrl = await resolveApiBaseUrl();
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
throw new ShareLinkError(
|
||||||
|
"Share link is invalid, expired, or revoked",
|
||||||
|
"not-found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
throw new ShareLinkError(
|
||||||
|
"Too many attempts, please try again shortly",
|
||||||
|
"rate-limited",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SESSION SHARING (authenticated owner-side API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
export type SessionShareType = "link" | "user";
|
||||||
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
|
export interface SessionShareRecord {
|
||||||
|
id: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: SessionShareProtocol;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId: string | null;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId: string | null;
|
||||||
|
linkToken: string | null;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
lastJoinedAt: string | null;
|
||||||
|
joinCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionShareRequest {
|
||||||
|
hostId: number;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
protocol: SessionShareProtocol;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId?: string;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
expiryHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionShareResponse {
|
||||||
|
shareId: string;
|
||||||
|
linkToken: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionShare(
|
||||||
|
request: CreateSessionShareRequest,
|
||||||
|
): Promise<CreateSessionShareResponse> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/session-sharing/create", request);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "create session share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveSessionShares(
|
||||||
|
hostId: number,
|
||||||
|
): Promise<{ shares: SessionShareRecord[] }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get(
|
||||||
|
`/session-sharing/host/${hostId}/active`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch active session shares");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSessionShare(
|
||||||
|
shareId: string,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete(`/session-sharing/${shareId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "revoke session share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSessionShareSession(
|
||||||
|
shareId: string,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(`/session-sharing/${shareId}/end`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "end shared session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GLOBAL ADMIN TOGGLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getSessionSharingGloballyEnabled(): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/session-sharing-enabled");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch session sharing enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionSharingGloballyEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/session-sharing-enabled", {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update session sharing enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ANALYTICS SETTINGS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/analytics-enabled");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch analytics enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAnalyticsEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/analytics-enabled", {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update analytics enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HOST DEFAULTS SETTINGS
|
// HOST DEFAULTS SETTINGS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
|
import {
|
||||||
|
authApi,
|
||||||
|
fileManagerApi,
|
||||||
|
handleApiError,
|
||||||
|
getFileManagerApiForSession,
|
||||||
|
setSessionOrigin,
|
||||||
|
clearSessionOrigin,
|
||||||
|
} from "@/main-axios";
|
||||||
|
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||||
import { fileLogger } from "@/lib/frontend-logger";
|
import { fileLogger } from "@/lib/frontend-logger";
|
||||||
import type { SSHHost } from "@/types/index";
|
import type { SSHHost } from "@/types/index";
|
||||||
|
|
||||||
@@ -72,7 +80,7 @@ export async function connectSSH(
|
|||||||
},
|
},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post(
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
"/ssh/connect",
|
"/ssh/connect",
|
||||||
{ sessionId, ...config },
|
{ sessionId, ...config },
|
||||||
{ timeout: 120000 },
|
{ timeout: 120000 },
|
||||||
@@ -121,12 +129,15 @@ export async function disconnectSSH(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/disconnect",
|
||||||
});
|
{ sessionId },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "disconnect SSH");
|
handleApiError(error, "disconnect SSH");
|
||||||
|
} finally {
|
||||||
|
clearSessionOrigin(sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
|
|||||||
totpCode: string,
|
totpCode: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/connect-totp",
|
||||||
totpCode,
|
{ sessionId, totpCode },
|
||||||
});
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "verify SSH TOTP");
|
handleApiError(error, "verify SSH TOTP");
|
||||||
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/connect-warpgate",
|
||||||
});
|
{ sessionId },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "verify SSH Warpgate");
|
handleApiError(error, "verify SSH Warpgate");
|
||||||
@@ -239,9 +251,10 @@ export async function getSSHStatus(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<{ connected: boolean }> {
|
): Promise<{ connected: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/status", {
|
const response = await getFileManagerApiForSession(sessionId).get(
|
||||||
params: { sessionId },
|
"/ssh/status",
|
||||||
});
|
{ params: { sessionId } },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "get SSH status");
|
handleApiError(error, "get SSH status");
|
||||||
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/keepalive",
|
||||||
});
|
{ sessionId },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "SSH keepalive");
|
handleApiError(error, "SSH keepalive");
|
||||||
@@ -266,9 +280,10 @@ export async function listSSHFiles(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<{ files: unknown[]; path: string }> {
|
): Promise<{ files: unknown[]; path: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
const response = await getFileManagerApiForSession(sessionId).get(
|
||||||
params: { sessionId, path },
|
"/ssh/listFiles",
|
||||||
});
|
{ params: { sessionId, path } },
|
||||||
|
);
|
||||||
return response.data || { files: [], path };
|
return response.data || { files: [], path };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "list SSH files");
|
handleApiError(error, "list SSH files");
|
||||||
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
const response = await getFileManagerApiForSession(sessionId).get(
|
||||||
params: { sessionId, path },
|
"/ssh/identifySymlink",
|
||||||
});
|
{ params: { sessionId, path } },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "identify SSH symlink");
|
handleApiError(error, "identify SSH symlink");
|
||||||
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
const response = await getFileManagerApiForSession(sessionId).get(
|
||||||
params: { sessionId, path },
|
"/ssh/resolvePath",
|
||||||
});
|
{ params: { sessionId, path } },
|
||||||
|
);
|
||||||
return response.data?.resolvedPath || path;
|
return response.data?.resolvedPath || path;
|
||||||
} catch {
|
} catch {
|
||||||
return path;
|
return path;
|
||||||
@@ -313,9 +330,10 @@ export async function readSSHFile(
|
|||||||
encoding?: "base64" | "utf8";
|
encoding?: "base64" | "utf8";
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
const response = await getFileManagerApiForSession(sessionId).get(
|
||||||
params: { sessionId, path },
|
"/ssh/readFile",
|
||||||
});
|
{ params: { sessionId, path } },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
@@ -340,13 +358,10 @@ export async function writeSSHFile(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/writeFile",
|
||||||
path,
|
{ sessionId, path, content, hostId, userId },
|
||||||
content,
|
);
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.data &&
|
response.data &&
|
||||||
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
|
|||||||
form.append("totalSize", String(file.size));
|
form.append("totalSize", String(file.size));
|
||||||
form.append("chunk", chunkBlob, fileName);
|
form.append("chunk", chunkBlob, fileName);
|
||||||
|
|
||||||
const response = await fileManagerApi.postForm(
|
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||||
"/ssh/uploadFileChunk",
|
"/ssh/uploadFileChunk",
|
||||||
form,
|
form,
|
||||||
{ timeout: 0 },
|
{ timeout: 0 },
|
||||||
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
|
|||||||
if (userId !== undefined) form.append("userId", userId);
|
if (userId !== undefined) form.append("userId", userId);
|
||||||
form.append("file", file, fileName);
|
form.append("file", file, fileName);
|
||||||
|
|
||||||
const response = await fileManagerApi.postForm(
|
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||||
"/ssh/uploadFileStream",
|
"/ssh/uploadFileStream",
|
||||||
form,
|
form,
|
||||||
{
|
{
|
||||||
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post(
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
"/ssh/downloadFile",
|
"/ssh/downloadFile",
|
||||||
{
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await fileManagerApi.post(
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
"/ssh/downloadFileStream",
|
"/ssh/downloadFileStream",
|
||||||
{ sessionId, path: filePath },
|
{ sessionId, path: filePath },
|
||||||
{ responseType: "blob", timeout: 0 },
|
{ responseType: "blob", timeout: 0 },
|
||||||
@@ -503,14 +518,10 @@ export async function createSSHFile(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/createFile",
|
||||||
path,
|
{ sessionId, path, fileName, content, hostId, userId },
|
||||||
fileName,
|
);
|
||||||
content,
|
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "create SSH file");
|
handleApiError(error, "create SSH file");
|
||||||
@@ -525,13 +536,10 @@ export async function createSSHFolder(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/createFolder",
|
||||||
path,
|
{ sessionId, path, folderName, hostId, userId },
|
||||||
folderName,
|
);
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "create SSH folder");
|
handleApiError(error, "create SSH folder");
|
||||||
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||||
data: {
|
"/ssh/deleteItem",
|
||||||
sessionId,
|
{
|
||||||
path,
|
data: {
|
||||||
isDirectory,
|
sessionId,
|
||||||
hostId,
|
path,
|
||||||
userId,
|
isDirectory,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "delete SSH item");
|
handleApiError(error, "delete SSH item");
|
||||||
@@ -566,7 +577,7 @@ export async function setSudoPassword(
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fileManagerApi.post("/sudo-password", {
|
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
|
||||||
sessionId,
|
sessionId,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
@@ -583,7 +594,7 @@ export async function copySSHItem(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post(
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
"/ssh/copyItem",
|
"/ssh/copyItem",
|
||||||
{
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -611,13 +622,10 @@ export async function renameSSHItem(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
const response = await getFileManagerApiForSession(sessionId).put(
|
||||||
sessionId,
|
"/ssh/renameItem",
|
||||||
oldPath,
|
{ sessionId, oldPath, newName, hostId, userId },
|
||||||
newName,
|
);
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "rename SSH item");
|
handleApiError(error, "rename SSH item");
|
||||||
@@ -633,7 +641,7 @@ export async function moveSSHItem(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.put(
|
const response = await getFileManagerApiForSession(sessionId).put(
|
||||||
"/ssh/moveItem",
|
"/ssh/moveItem",
|
||||||
{
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/changePermissions",
|
||||||
path,
|
{ sessionId, path, permissions, hostId, userId },
|
||||||
permissions,
|
);
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
fileLogger.success("SSH file permissions changed successfully", {
|
fileLogger.success("SSH file permissions changed successfully", {
|
||||||
operation: "change_permissions",
|
operation: "change_permissions",
|
||||||
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/extractArchive",
|
||||||
archivePath,
|
{ sessionId, archivePath, extractPath, hostId, userId },
|
||||||
extractPath,
|
);
|
||||||
hostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
fileLogger.success("Archive extracted successfully", {
|
fileLogger.success("Archive extracted successfully", {
|
||||||
operation: "extract_archive",
|
operation: "extract_archive",
|
||||||
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
sessionId,
|
"/ssh/compressFiles",
|
||||||
paths,
|
{
|
||||||
archiveName,
|
sessionId,
|
||||||
format: format || "zip",
|
paths,
|
||||||
hostId,
|
archiveName,
|
||||||
userId,
|
format: format || "zip",
|
||||||
});
|
hostId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fileLogger.success("Files compressed successfully", {
|
fileLogger.success("Files compressed successfully", {
|
||||||
operation: "compress_files",
|
operation: "compress_files",
|
||||||
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
|
|||||||
host: SSHHost,
|
host: SSHHost,
|
||||||
): Promise<EnsureSSHSessionResult> {
|
): Promise<EnsureSSHSessionResult> {
|
||||||
const sessionId = host.id.toString();
|
const sessionId = host.id.toString();
|
||||||
|
const origin = await resolveConnectionOrigin({
|
||||||
|
connectionType: host.connectionType,
|
||||||
|
connectionOrigin: host.connectionOrigin,
|
||||||
|
});
|
||||||
|
setSessionOrigin(sessionId, origin);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await getSSHStatus(sessionId);
|
const status = await getSSHStatus(sessionId);
|
||||||
if (status?.connected) {
|
if (status?.connected) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
import {
|
||||||
|
authApi,
|
||||||
|
handleApiError,
|
||||||
|
tunnelApi,
|
||||||
|
getRemoteTunnelApi,
|
||||||
|
isElectron,
|
||||||
|
} from "@/main-axios";
|
||||||
import type {
|
import type {
|
||||||
C2STunnelPreset,
|
C2STunnelPreset,
|
||||||
TunnelConfig,
|
TunnelConfig,
|
||||||
@@ -9,13 +15,46 @@ import type {
|
|||||||
|
|
||||||
// TUNNEL MANAGEMENT
|
// TUNNEL MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
|
||||||
|
// safe to read from both the embedded backend and a connected remote server
|
||||||
|
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
|
||||||
|
// NOT origin-routed: they resolve the target host by numeric database id
|
||||||
|
// against whichever backend receives the request, and a synced host has a
|
||||||
|
// different numeric id in each database (only its syncId matches across
|
||||||
|
// them) -- routing those calls to a remote backend would need a
|
||||||
|
// local-id-to-remote-id resolution step that doesn't exist yet. They always
|
||||||
|
// target the embedded local backend for now.
|
||||||
|
|
||||||
|
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||||
|
if (!isElectron()) return false;
|
||||||
|
try {
|
||||||
|
const config = (await window.electronAPI?.invoke?.(
|
||||||
|
"get-remote-sync-config",
|
||||||
|
)) as { serverUrl?: string } | null;
|
||||||
|
return !!config?.serverUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTunnelStatuses(): Promise<
|
export async function getTunnelStatuses(): Promise<
|
||||||
Record<string, TunnelStatus>
|
Record<string, TunnelStatus>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
const response = await tunnelApi.get("/tunnel/status");
|
const [localResult, remoteConnected] = await Promise.all([
|
||||||
return response.data || {};
|
tunnelApi.get("/tunnel/status"),
|
||||||
|
isRemoteSyncConnected(),
|
||||||
|
]);
|
||||||
|
const localStatuses = localResult.data || {};
|
||||||
|
if (!remoteConnected) return localStatuses;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
|
||||||
|
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||||
|
} catch {
|
||||||
|
return localStatuses;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch tunnel statuses");
|
handleApiError(error, "fetch tunnel statuses");
|
||||||
}
|
}
|
||||||
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let latestLocal: Record<string, TunnelStatus> = {};
|
||||||
|
let latestRemote: Record<string, TunnelStatus> = {};
|
||||||
|
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const emitMerged = () => {
|
||||||
|
onStatuses({ ...latestLocal, ...latestRemote });
|
||||||
|
};
|
||||||
|
|
||||||
source.addEventListener("statuses", (event) => {
|
source.addEventListener("statuses", (event) => {
|
||||||
try {
|
try {
|
||||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||||
|
emitMerged();
|
||||||
} catch {
|
} catch {
|
||||||
onError?.();
|
onError?.();
|
||||||
}
|
}
|
||||||
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
|
|||||||
onError?.();
|
onError?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => source.close();
|
// Remote tunnel status has no SSE stream exposed to the desktop app yet,
|
||||||
|
// so poll it at a modest interval when a remote server is connected.
|
||||||
|
isRemoteSyncConnected().then((connected) => {
|
||||||
|
if (!connected) return;
|
||||||
|
const pollRemote = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getRemoteTunnelApi().get("/tunnel/status");
|
||||||
|
latestRemote = result.data || {};
|
||||||
|
emitMerged();
|
||||||
|
} catch {
|
||||||
|
// remote unreachable this tick -- keep last known remote statuses
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pollRemote();
|
||||||
|
remotePollTimer = setInterval(pollRemote, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.close();
|
||||||
|
if (remotePollTimer) clearInterval(remotePollTimer);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTunnelStatusByName(
|
export async function getTunnelStatusByName(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user