mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
+38
-251
@@ -218,30 +218,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ai(/.*)?$ {
|
|
||||||
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;
|
|
||||||
|
|
||||||
# The chat endpoint streams server-sent events; buffering would
|
|
||||||
# hold tokens back until the whole reply finished.
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/automations(/.*)?$ {
|
|
||||||
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 ~ ^/notification-channels(/.*)?$ {
|
location ~ ^/notification-channels(/.*)?$ {
|
||||||
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;
|
||||||
@@ -282,21 +258,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/fleets(/.*)?$ {
|
|
||||||
client_max_body_size 200m;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/vault(/.*)?$ {
|
location ~ ^/vault(/.*)?$ {
|
||||||
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;
|
||||||
@@ -324,18 +285,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/proxmox(/.*)?$ {
|
|
||||||
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;
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 120s;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||||
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;
|
||||||
@@ -372,16 +321,53 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/plugin-api(/.*)?$ {
|
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||||
|
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||||
|
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||||
|
# (AI, Proxmox discovery) reach the browser as they are produced, and
|
||||||
|
# request buffering is off so large uploads stream through.
|
||||||
|
location ^~ /plugin-api/ {
|
||||||
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
client_max_body_size 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every plugin's WebSockets: terminal, Guacamole display, Docker
|
||||||
|
# console. Served at /plugin-ws/<id>/<path> from the same backend.
|
||||||
|
location ^~ /plugin-ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/open-tabs(/.*)?$ {
|
location ~ ^/open-tabs(/.*)?$ {
|
||||||
@@ -393,15 +379,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/workspaces(/.*)?$ {
|
|
||||||
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 ~ ^/user-preferences(/.*)?$ {
|
location ~ ^/user-preferences(/.*)?$ {
|
||||||
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;
|
||||||
@@ -549,74 +526,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /tailscale/ {
|
|
||||||
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 /ssh/websocket/ {
|
|
||||||
proxy_pass http://127.0.0.1:30002/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /guacamole/websocket/ {
|
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
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;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/guacamole(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $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 ~ ^/session-sharing(/.*)?$ {
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
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;
|
||||||
@@ -741,15 +650,6 @@ http {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/network-topology(/.*)?$ {
|
|
||||||
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 /health {
|
location /health {
|
||||||
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;
|
||||||
@@ -759,81 +659,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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 ~ ^/metrics(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(refresh|host-updated)$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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-metrics(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/proxmox-stats(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/global-settings(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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 ~ ^/uptime(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30006;
|
|
||||||
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 ~ ^/activity(/.*)?$ {
|
location ~ ^/activity(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30006;
|
proxy_pass http://127.0.0.1:30006;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -861,31 +686,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
|
||||||
proxy_pass http://127.0.0.1:30009/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
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;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- tmux-monitor begin ---
|
# --- tmux-monitor begin ---
|
||||||
location ~ ^/tmux_monitor(/.*)?$ {
|
location ~ ^/tmux_monitor(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30010;
|
proxy_pass http://127.0.0.1:30010;
|
||||||
@@ -901,19 +701,6 @@ http {
|
|||||||
}
|
}
|
||||||
# --- tmux-monitor end ---
|
# --- tmux-monitor end ---
|
||||||
|
|
||||||
location ~ ^/docker(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30007;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /serial/websocket/ {
|
location ^~ /serial/websocket/ {
|
||||||
proxy_pass http://127.0.0.1:30011/;
|
proxy_pass http://127.0.0.1:30011/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
+38
-251
@@ -198,30 +198,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ai(/.*)?$ {
|
|
||||||
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;
|
|
||||||
|
|
||||||
# The chat endpoint streams server-sent events; buffering would
|
|
||||||
# hold tokens back until the whole reply finished.
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/automations(/.*)?$ {
|
|
||||||
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 ~ ^/notification-channels(/.*)?$ {
|
location ~ ^/notification-channels(/.*)?$ {
|
||||||
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;
|
||||||
@@ -262,21 +238,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/fleets(/.*)?$ {
|
|
||||||
client_max_body_size 200m;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/vault(/.*)?$ {
|
location ~ ^/vault(/.*)?$ {
|
||||||
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;
|
||||||
@@ -304,18 +265,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/proxmox(/.*)?$ {
|
|
||||||
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;
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 120s;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||||
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;
|
||||||
@@ -352,16 +301,53 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/plugin-api(/.*)?$ {
|
# Every plugin's HTTP routes. One block, no per-plugin entries: a
|
||||||
|
# plugin serves at /plugin-api/<id>/ on the main backend and never
|
||||||
|
# gets a port or a block of its own. Buffering is off so SSE streams
|
||||||
|
# (AI, Proxmox discovery) reach the browser as they are produced, and
|
||||||
|
# request buffering is off so large uploads stream through.
|
||||||
|
location ^~ /plugin-api/ {
|
||||||
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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
client_max_body_size 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every plugin's WebSockets: terminal, Guacamole display, Docker
|
||||||
|
# console. Served at /plugin-ws/<id>/<path> from the same backend.
|
||||||
|
location ^~ /plugin-ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/open-tabs(/.*)?$ {
|
location ~ ^/open-tabs(/.*)?$ {
|
||||||
@@ -373,15 +359,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/workspaces(/.*)?$ {
|
|
||||||
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 ~ ^/user-preferences(/.*)?$ {
|
location ~ ^/user-preferences(/.*)?$ {
|
||||||
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;
|
||||||
@@ -527,74 +504,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /tailscale/ {
|
|
||||||
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 /ssh/websocket/ {
|
|
||||||
proxy_pass http://127.0.0.1:30002/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /guacamole/websocket/ {
|
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
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;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/guacamole(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $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 ~ ^/session-sharing(/.*)?$ {
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
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;
|
||||||
@@ -717,15 +626,6 @@ http {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/network-topology(/.*)?$ {
|
|
||||||
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 /health {
|
location /health {
|
||||||
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;
|
||||||
@@ -735,81 +635,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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 ~ ^/metrics(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 60s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(refresh|host-updated)$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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-metrics(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/proxmox-stats(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
|
||||||
proxy_send_timeout 600s;
|
|
||||||
proxy_read_timeout 600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/global-settings(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30005;
|
|
||||||
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 ~ ^/uptime(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30006;
|
|
||||||
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 ~ ^/activity(/.*)?$ {
|
location ~ ^/activity(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30006;
|
proxy_pass http://127.0.0.1:30006;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -837,31 +662,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
|
||||||
proxy_pass http://127.0.0.1:30009/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
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;
|
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
|
||||||
proxy_send_timeout 86400s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_request_buffering off;
|
|
||||||
|
|
||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- tmux-monitor begin ---
|
# --- tmux-monitor begin ---
|
||||||
location ~ ^/tmux_monitor(/.*)?$ {
|
location ~ ^/tmux_monitor(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30010;
|
proxy_pass http://127.0.0.1:30010;
|
||||||
@@ -877,19 +677,6 @@ http {
|
|||||||
}
|
}
|
||||||
# --- tmux-monitor end ---
|
# --- tmux-monitor end ---
|
||||||
|
|
||||||
location ~ ^/docker(/.*)?$ {
|
|
||||||
proxy_pass http://127.0.0.1:30007;
|
|
||||||
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;
|
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /serial/websocket/ {
|
location ^~ /serial/websocket/ {
|
||||||
proxy_pass http://127.0.0.1:30011/;
|
proxy_pass http://127.0.0.1:30011/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
+2
-2
@@ -1024,8 +1024,8 @@ function getBackendPidFilePath() {
|
|||||||
// Manager) rather than through the normal quit flow, will-quit never fires
|
// Manager) rather than through the normal quit flow, will-quit never fires
|
||||||
// and stopBackendServer() never runs -- the forked backend child is a
|
// and stopBackendServer() never runs -- the forked backend child is a
|
||||||
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
||||||
// and holding every port the backend binds (30001, 30003-30008, 30010,
|
// and holding every port the backend binds (30001, 30003, 30004, 30006,
|
||||||
// 30012...). Every subsequent launch's own backend then fails outright
|
// 30010-30012). Every subsequent launch's own backend then fails outright
|
||||||
// with EADDRINUSE and the app is stuck until something manually kills the
|
// with EADDRINUSE and the app is stuck until something manually kills the
|
||||||
// orphan. Reap any such leftover process, identified by PID file, before
|
// orphan. Reap any such leftover process, identified by PID file, before
|
||||||
// spawning a new one.
|
// spawning a new one.
|
||||||
|
|||||||
@@ -126,13 +126,51 @@ frozen array for a server that predates the endpoint.
|
|||||||
### 6. HTTP and WebSockets
|
### 6. HTTP and WebSockets
|
||||||
|
|
||||||
Plugin routes live under `/plugin-api/<id>/` and sockets under
|
Plugin routes live under `/plugin-api/<id>/` and sockets under
|
||||||
`/plugin-ws/<id>/`, served by the main backend with core auth. No plugin gets
|
`/plugin-ws/<id>/<path>`, served by the main backend with core auth. No plugin
|
||||||
its own port or nginx block.
|
gets its own port or nginx block.
|
||||||
|
|
||||||
**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no
|
**A4 delivered this.** A plugin calls `ctx.http.router(options)` and gets an
|
||||||
routes registered, so it returns 404. The bundled plugins still keep their
|
Express Router core mounts at `/plugin-api/<id>/`, or `ctx.ws.route(path,
|
||||||
original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`,
|
handler)` for a socket. Core runs the same middleware in front of every plugin:
|
||||||
and four still own ports of their own.
|
|
||||||
|
1. **Auth**, unless the path is listed in `options.public`. A public path is
|
||||||
|
audited when the router is registered and logged on every request, because
|
||||||
|
"this plugin opened a hole in auth" should be findable later. It matches the
|
||||||
|
full path, with `:param` segments allowed, so declaring `/webhook/:token`
|
||||||
|
public cannot open `/webhook/:token/anything`.
|
||||||
|
2. **The actor**, from the user core authenticated, never from the request body.
|
||||||
|
3. **An enabled check** returning 503 while the plugin is disabled. That is a
|
||||||
|
different fact from 404 ("no such plugin") and a caller can act on it.
|
||||||
|
4. **Body limits**, defaulting to the 2mb core itself accepts. `database.ts`
|
||||||
|
skips its own global parser for `/plugin-api` so a plugin's `bodyLimit`
|
||||||
|
actually applies; `rawBody` turns the parsers off for a router that parses
|
||||||
|
its own (multer, a raw body for signature checks).
|
||||||
|
5. **An error wrapper** that logs against the plugin, never leaks a stack, and
|
||||||
|
feeds the error budget.
|
||||||
|
|
||||||
|
`ctx.rbac.require(permission)` adds a per-route RBAC gate, and a plugin may only
|
||||||
|
require a permission it declares itself.
|
||||||
|
|
||||||
|
Both surfaces need `network:serve`. The declaration is checked when the router
|
||||||
|
or route is created; the **grant** is checked per request and per upgrade,
|
||||||
|
because it lives in the database and `router()` has to be synchronous. A revoked
|
||||||
|
grant therefore takes effect on the next request without a restart: HTTP answers
|
||||||
|
403, an upgrade is refused 403.
|
||||||
|
|
||||||
|
Sockets are served from the main server's `upgrade` event, on both the HTTP
|
||||||
|
server and the direct HTTPS one. Auth is the same `utils/ws-auth.ts` path every
|
||||||
|
other Termix socket uses, and a token still awaiting TOTP is refused exactly as
|
||||||
|
it is for HTTP. Binary frames and backpressure are untouched. Everything a
|
||||||
|
plugin registered is disposed on deactivate, and live sockets are **closed**,
|
||||||
|
not merely unrouted: a terminal session must not outlive the plugin that owns it.
|
||||||
|
|
||||||
|
`ctx.ws.upgrade(path, handler)` hands over the raw upgrade, after core has
|
||||||
|
authenticated it, for a library that insists on owning its own WebSocketServer.
|
||||||
|
guacamole-lite is why it exists. Three routes are public because they
|
||||||
|
authenticate themselves in a way core cannot: the terminal (a share-link guest
|
||||||
|
arrives with a share token), the Docker console (per-message auth), and the
|
||||||
|
Guacamole display (a single-use encrypted connection token in the query, minted
|
||||||
|
by an authenticated route).
|
||||||
|
|
||||||
### 7. Settings
|
### 7. Settings
|
||||||
|
|
||||||
@@ -407,13 +445,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
|||||||
| `ctx.currentActor()` | none | **A1** |
|
| `ctx.currentActor()` | none | **A1** |
|
||||||
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
|
||||||
| `ctx.sync.registerEntity` | none | **A3** |
|
| `ctx.sync.registerEntity` | none | **A3** |
|
||||||
| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 |
|
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
|
||||||
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 |
|
| `ctx.rbac.require` | own permissions only | **A4** |
|
||||||
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 |
|
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
|
||||||
|
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
|
||||||
| `ctx.settings.*` | `settings:read-core` | A6 |
|
| `ctx.settings.*` | `settings:read-core` | A6 |
|
||||||
| `ctx.notify.*` | `notify:send` | A6 |
|
| `ctx.notify.*` | `notify:send` | A6 |
|
||||||
| `ctx.auth.*` | `auth:provide` | A8 |
|
| `ctx.auth.*` | `auth:provide` | A8 |
|
||||||
| `ctx.fetch` | `network:outbound` | A4 |
|
| `ctx.fetch` | `network:outbound` | B |
|
||||||
|
|
||||||
### The actor
|
### The actor
|
||||||
|
|
||||||
@@ -581,7 +620,7 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
|||||||
| Core importing a plugin backend | **Error** | 0 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin components | Warning | 16 files | A7 |
|
| The shell importing plugin components | Warning | 16 files | A7 |
|
||||||
| A plugin importing core by relative path | Warning | 71 files | D1 |
|
| A plugin importing core by relative path | Warning | 70 files | D1 |
|
||||||
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
| A plugin importing another plugin's source | Warning | 3 files | B18 |
|
||||||
|
|
||||||
A warning does not fail a build, so the counts are held by
|
A warning does not fail a build, so the counts are held by
|
||||||
@@ -610,11 +649,9 @@ Known specifics:
|
|||||||
degrade silently when automations is absent, so host-metrics declares it as
|
degrade silently when automations is absent, so host-metrics declares it as
|
||||||
an `optionalDependency` (added in A2; it previously declared nothing, and
|
an `optionalDependency` (added in A2; it previously declared nothing, and
|
||||||
the loader had no reason to order the two).
|
the loader had no reason to order the two).
|
||||||
- Eight plugins keep their original core URLs through the dispatchers in
|
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
|
||||||
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
|
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage
|
||||||
moves them.
|
30012. Each keeps its nginx block until its own Phase B step. No plugin owns
|
||||||
- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005,
|
a port any more (A4).
|
||||||
docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main
|
|
||||||
server.
|
|
||||||
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
|
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
|
||||||
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
|
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
|
||||||
@@ -150,6 +150,130 @@ export interface PluginSecrets {
|
|||||||
) => Promise<string | null>;
|
) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express types, structurally. The SDK does not depend on express: a plugin
|
||||||
|
* brings its own copy, and a nominal type from core's would not match it.
|
||||||
|
*/
|
||||||
|
export interface PluginRequestLike {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
path: string;
|
||||||
|
headers: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginResponseLike {
|
||||||
|
status: (code: number) => PluginResponseLike;
|
||||||
|
json: (body: unknown) => unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginNextFunction = (error?: unknown) => void;
|
||||||
|
|
||||||
|
export type PluginMiddleware = (
|
||||||
|
req: PluginRequestLike,
|
||||||
|
res: PluginResponseLike,
|
||||||
|
next: PluginNextFunction,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export interface PluginRouterOptions {
|
||||||
|
/**
|
||||||
|
* Paths served without authentication, relative to the plugin's mount point
|
||||||
|
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
|
||||||
|
*
|
||||||
|
* For the handful of routes an unauthenticated third party has to reach: an
|
||||||
|
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
||||||
|
* is registered and every request that uses one is logged, because "this
|
||||||
|
* plugin opened a hole in auth" is exactly the thing an operator should be
|
||||||
|
* able to find later.
|
||||||
|
*/
|
||||||
|
public?: readonly string[];
|
||||||
|
/** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */
|
||||||
|
bodyLimit?: string;
|
||||||
|
/**
|
||||||
|
* Skips the built-in body parsers entirely, for a router that parses its own
|
||||||
|
* (multer, a raw body for signature checking, a proxied stream).
|
||||||
|
*/
|
||||||
|
rawBody?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP routes, served at /plugin-api/<id>/ by the main backend server.
|
||||||
|
*
|
||||||
|
* No plugin gets its own port or nginx block. Core runs auth, the actor, the
|
||||||
|
* enabled check, body limits and an error wrapper in front of every route, so
|
||||||
|
* a plugin's router only has to handle its own paths.
|
||||||
|
*/
|
||||||
|
export interface PluginHttp {
|
||||||
|
/**
|
||||||
|
* Returns an Express Router mounted at /plugin-api/<id>/. Call it once in
|
||||||
|
* activate and register routes on the result; it is unmounted automatically
|
||||||
|
* on deactivate.
|
||||||
|
*/
|
||||||
|
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RBAC middleware for a plugin's own routes. */
|
||||||
|
export interface PluginRbac {
|
||||||
|
/**
|
||||||
|
* Middleware that rejects a request whose user lacks `permission`.
|
||||||
|
*
|
||||||
|
* A plugin may only require a permission it declares in
|
||||||
|
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
|
||||||
|
* and borrow someone else's authority.
|
||||||
|
*/
|
||||||
|
require: (permission: string) => PluginMiddleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginWebSocketConnection {
|
||||||
|
/** The authenticated user for this socket. */
|
||||||
|
readonly userId: string;
|
||||||
|
/** The upgrade request, for query parameters and headers. */
|
||||||
|
readonly request: unknown;
|
||||||
|
/** The ws WebSocket. Binary frames and backpressure work as usual. */
|
||||||
|
readonly socket: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginWebSocketHandler = (
|
||||||
|
connection: PluginWebSocketConnection,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface PluginWebSocketOptions {
|
||||||
|
/**
|
||||||
|
* Serves the upgrade without core authentication. The handler gets an empty
|
||||||
|
* userId and must authenticate the socket itself, which is what a share-link
|
||||||
|
* guest or a token-in-query protocol needs.
|
||||||
|
*/
|
||||||
|
public?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket routes, served at /plugin-ws/<id>/<path> through the main server's
|
||||||
|
* upgrade event. Every socket a plugin opened is closed on deactivate.
|
||||||
|
*/
|
||||||
|
export interface PluginWebSockets {
|
||||||
|
route: (
|
||||||
|
path: string,
|
||||||
|
handler: PluginWebSocketHandler,
|
||||||
|
options?: PluginWebSocketOptions,
|
||||||
|
) => void;
|
||||||
|
/**
|
||||||
|
* Hands the raw upgrade to the plugin, for a library that insists on owning
|
||||||
|
* its own WebSocket server (guacamole-lite attaches this way). Auth and the
|
||||||
|
* actor still run first.
|
||||||
|
*/
|
||||||
|
upgrade: (
|
||||||
|
path: string,
|
||||||
|
handler: (
|
||||||
|
request: unknown,
|
||||||
|
socket: unknown,
|
||||||
|
head: unknown,
|
||||||
|
userId: string,
|
||||||
|
) => void,
|
||||||
|
options?: PluginWebSocketOptions,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anything the runtime should undo when the plugin deactivates. Everything
|
* Anything the runtime should undo when the plugin deactivates. Everything
|
||||||
* registered through ctx is tracked automatically; this is for resources a
|
* registered through ctx is tracked automatically; this is for resources a
|
||||||
@@ -170,6 +294,9 @@ export interface PluginContext {
|
|||||||
readonly registry: PluginRegistry;
|
readonly registry: PluginRegistry;
|
||||||
readonly services: PluginServices;
|
readonly services: PluginServices;
|
||||||
readonly secrets: PluginSecrets;
|
readonly secrets: PluginSecrets;
|
||||||
|
readonly http: PluginHttp;
|
||||||
|
readonly ws: PluginWebSockets;
|
||||||
|
readonly rbac: PluginRbac;
|
||||||
readonly disposables: PluginDisposables;
|
readonly disposables: PluginDisposables;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
import type { TermixApp } from "./frontend.js";
|
import type { TermixApp } from "./frontend.js";
|
||||||
import type { PluginManifest } from "./manifest.js";
|
import type { PluginManifest } from "./manifest.js";
|
||||||
import type { PluginTableDefinition } from "./db.js";
|
import type { PluginTableDefinition } from "./db.js";
|
||||||
|
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
|
||||||
import type { SyncEntityRegistration } from "./backend.js";
|
import type { SyncEntityRegistration } from "./backend.js";
|
||||||
|
|
||||||
export interface FakeContextOptions {
|
export interface FakeContextOptions {
|
||||||
@@ -38,6 +39,10 @@ export interface FakePluginContext {
|
|||||||
tables: PluginTableDefinition[];
|
tables: PluginTableDefinition[];
|
||||||
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
||||||
syncEntities: SyncEntityRegistration[];
|
syncEntities: SyncEntityRegistration[];
|
||||||
|
/** WebSocket routes registered through ctx.ws, in order. */
|
||||||
|
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||||
|
/** Router options passed to ctx.http.router, in order. */
|
||||||
|
httpRouters: Array<PluginRouterOptions | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function noopLogger(): PluginLogger {
|
function noopLogger(): PluginLogger {
|
||||||
@@ -63,6 +68,8 @@ export function createFakeContext(
|
|||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
const syncEntities: SyncEntityRegistration[] = [];
|
||||||
|
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||||
|
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
|
|
||||||
@@ -153,6 +160,30 @@ export function createFakeContext(
|
|||||||
getShared: async () => null,
|
getShared: async () => null,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
http: {
|
||||||
|
// No express here: a test that needs a real router should mount the
|
||||||
|
// plugin's own route module against its own express app.
|
||||||
|
router: (routerOptions) => {
|
||||||
|
httpRouters.push(routerOptions);
|
||||||
|
return undefined as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ws: {
|
||||||
|
route: (path) => {
|
||||||
|
wsRoutes.push({ path, raw: false });
|
||||||
|
},
|
||||||
|
upgrade: (path) => {
|
||||||
|
wsRoutes.push({ path, raw: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rbac: {
|
||||||
|
// Passes everything through: a test asserting a permission gate should
|
||||||
|
// drive PermissionManager, not this double.
|
||||||
|
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
||||||
|
},
|
||||||
|
|
||||||
disposables,
|
disposables,
|
||||||
|
|
||||||
asUser: async (userId, fn) => {
|
asUser: async (userId, fn) => {
|
||||||
@@ -168,7 +199,16 @@ export function createFakeContext(
|
|||||||
currentActor: () => actor,
|
currentActor: () => actor,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ctx, disposals, emitted, kv, tables, syncEntities };
|
return {
|
||||||
|
ctx,
|
||||||
|
disposals,
|
||||||
|
emitted,
|
||||||
|
kv,
|
||||||
|
tables,
|
||||||
|
syncEntities,
|
||||||
|
wsRoutes,
|
||||||
|
httpRouters,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockContextOptions {
|
export interface MockContextOptions {
|
||||||
@@ -285,6 +325,24 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
on: (topic, listener) => ctx.events.on(topic, listener),
|
on: (topic, listener) => ctx.events.on(topic, listener),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
http: {
|
||||||
|
router: (routerOptions) => {
|
||||||
|
require("network:serve");
|
||||||
|
return ctx.http.router(routerOptions);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ws: {
|
||||||
|
route: (path, handler, wsOptions) => {
|
||||||
|
require("network:serve");
|
||||||
|
ctx.ws.route(path, handler, wsOptions);
|
||||||
|
},
|
||||||
|
upgrade: (path, handler, wsOptions) => {
|
||||||
|
require("network:serve");
|
||||||
|
ctx.ws.upgrade(path, handler, wsOptions);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...base, ctx: gatedCtx, settings, checked };
|
return { ...base, ctx: gatedCtx, settings, checked };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"secrets:own",
|
"secrets:own",
|
||||||
"network:outbound",
|
"network:outbound",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { startAiService, stopAiService } from "./routes.js";
|
|||||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startAiService();
|
startAiService(ctx.http.router());
|
||||||
|
|
||||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||||
// stays ours. Rotating or deleting the provider takes effect on the next
|
// stays ours. Rotating or deleting the provider takes effect on the next
|
||||||
@@ -24,7 +24,7 @@ export async function activate(ctx: PluginContext) {
|
|||||||
return withSecret?.apiKey ?? null;
|
return withSecret?.apiKey ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.log.info("AI assistant router registered at /ai");
|
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import {
|
|
||||||
registerAiRouter,
|
|
||||||
unregisterAiRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/ai-dispatch.js";
|
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
@@ -1339,12 +1335,12 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Called from activate(). Mounts this router at /ai via the shared dispatcher. */
|
/** Called from activate(). Mounts this router at /plugin-api/ai. */
|
||||||
export function startAiService(): void {
|
export function startAiService(mountOn: Router): void {
|
||||||
registerAiRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from deactivate(). /ai/* falls back to 404 until reactivated. */
|
/** Called from deactivate(). /ai/* falls back to 404 until reactivated. */
|
||||||
export function stopAiService(): void {
|
export function stopAiService(): void {
|
||||||
unregisterAiRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"events:core",
|
"events:core",
|
||||||
"notify:send",
|
"notify:send",
|
||||||
"network:outbound",
|
"network:outbound",
|
||||||
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import {
|
|||||||
} from "./scheduler.js";
|
} from "./scheduler.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startAutomationsService();
|
startAutomationsService(
|
||||||
|
// The webhook route authenticates on its own per-automation token rather
|
||||||
|
// than a session, which is the point of an inbound webhook.
|
||||||
|
ctx.http.router({ public: ["/webhook/:token"] }),
|
||||||
|
);
|
||||||
startAutomationScheduler();
|
startAutomationScheduler();
|
||||||
ctx.log.info(
|
ctx.log.info(
|
||||||
"Automations router registered at /automations; scheduler started",
|
"Automations routes mounted at /plugin-api/automations; scheduler started",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response, type Router } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||||
import type {
|
import type {
|
||||||
AutomationDefinition,
|
AutomationDefinition,
|
||||||
@@ -19,10 +19,6 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab
|
|||||||
import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js";
|
import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js";
|
||||||
import { AutomationEngine } from "./engine.js";
|
import { AutomationEngine } from "./engine.js";
|
||||||
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
|
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
|
||||||
import {
|
|
||||||
registerAutomationsRouter,
|
|
||||||
unregisterAutomationsRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/automation-dispatch.js";
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -821,12 +817,12 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|||||||
|
|
||||||
/** Called from activate(). Mounts this router at /automations via the shared
|
/** Called from activate(). Mounts this router at /automations via the shared
|
||||||
* dispatcher. */
|
* dispatcher. */
|
||||||
export function startAutomationsService(): void {
|
export function startAutomationsService(mountOn: Router): void {
|
||||||
registerAutomationsRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from deactivate(). /automations/* falls back to 404 until
|
/** Called from deactivate(). /automations/* falls back to 404 until
|
||||||
* reactivated. */
|
* reactivated. */
|
||||||
export function stopAutomationsService(): void {
|
export function stopAutomationsService(): void {
|
||||||
unregisterAutomationsRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ export interface AutomationInput {
|
|||||||
|
|
||||||
export async function listAutomations(): Promise<AutomationRow[]> {
|
export async function listAutomations(): Promise<AutomationRow[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/automations")).data;
|
return (await authApi.get("/plugin-api/automations")).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch automations");
|
throw handleApiError(error, "fetch automations");
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ export async function createAutomation(
|
|||||||
input: AutomationInput,
|
input: AutomationInput,
|
||||||
): Promise<AutomationRow> {
|
): Promise<AutomationRow> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/automations", input)).data;
|
return (await authApi.post("/plugin-api/automations", input)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create automation");
|
throw handleApiError(error, "create automation");
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ export async function updateAutomation(
|
|||||||
input: Partial<AutomationInput>,
|
input: Partial<AutomationInput>,
|
||||||
): Promise<AutomationRow> {
|
): Promise<AutomationRow> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.put(`/automations/${id}`, input)).data;
|
return (await authApi.put(`/plugin-api/automations/${id}`, input)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update automation");
|
throw handleApiError(error, "update automation");
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ export async function updateAutomation(
|
|||||||
|
|
||||||
export async function deleteAutomation(id: number): Promise<void> {
|
export async function deleteAutomation(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.delete(`/automations/${id}`);
|
await authApi.delete(`/plugin-api/automations/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete automation");
|
throw handleApiError(error, "delete automation");
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,8 @@ export async function runAutomation(
|
|||||||
options: { dryRun?: boolean } = {},
|
options: { dryRun?: boolean } = {},
|
||||||
): Promise<AutomationRunOutcome> {
|
): Promise<AutomationRunOutcome> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post(`/automations/${id}/run`, options)).data;
|
return (await authApi.post(`/plugin-api/automations/${id}/run`, options))
|
||||||
|
.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "run automation");
|
throw handleApiError(error, "run automation");
|
||||||
}
|
}
|
||||||
@@ -125,8 +126,11 @@ export async function listAutomationRuns(
|
|||||||
options: { automationId?: number; limit?: number; offset?: number } = {},
|
options: { automationId?: number; limit?: number; offset?: number } = {},
|
||||||
): Promise<AutomationRunRow[]> {
|
): Promise<AutomationRunRow[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/automations/runs/history", { params: options }))
|
return (
|
||||||
.data;
|
await authApi.get("/plugin-api/automations/runs/history", {
|
||||||
|
params: options,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch automation runs");
|
throw handleApiError(error, "fetch automation runs");
|
||||||
}
|
}
|
||||||
@@ -136,7 +140,8 @@ export async function listAutomationRunSteps(
|
|||||||
runId: number,
|
runId: number,
|
||||||
): Promise<AutomationRunStepRow[]> {
|
): Promise<AutomationRunStepRow[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get(`/automations/runs/${runId}/steps`)).data;
|
return (await authApi.get(`/plugin-api/automations/runs/${runId}/steps`))
|
||||||
|
.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch run steps");
|
throw handleApiError(error, "fetch run steps");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ interface SSHSession {
|
|||||||
|
|
||||||
const activeSessions = new Map<string, SSHSession>();
|
const activeSessions = new Map<string, SSHSession>();
|
||||||
|
|
||||||
const CONSOLE_PORT = 30009;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Created by startConsoleServer(), not at module load.
|
* Created by startConsoleServer(), not at module load.
|
||||||
*
|
*
|
||||||
* Importing this file used to bind the port as a side effect, which meant the
|
* Importing this file used to bind a port as a side effect, which meant the
|
||||||
* plugin could not be disabled and re-enabled: the module stays in the ESM
|
* plugin could not be disabled and re-enabled: the module stays in the ESM
|
||||||
* cache, so the second activate() got a server that had already been closed.
|
* cache, so the second activate() got a server that had already been closed.
|
||||||
|
* There is no port now -- core routes /plugin-ws/docker/console here -- but
|
||||||
|
* the same lifecycle applies to the server object itself.
|
||||||
*/
|
*/
|
||||||
let wss: WebSocketServer | null = null;
|
let wss: WebSocketServer | null = null;
|
||||||
|
|
||||||
@@ -60,40 +60,46 @@ function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
|
|||||||
void onConsoleConnection(ws, req);
|
void onConsoleConnection(ws, req);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts the console WebSocket server. Call from the plugin's activate(). */
|
/** Builds the console WebSocket server. Call from the plugin's activate(). */
|
||||||
export function startConsoleServer(): Promise<void> {
|
export function startConsoleServer(): Promise<void> {
|
||||||
if (wss) return Promise.resolve();
|
if (wss) return Promise.resolve();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const server = new WebSocketServer({
|
||||||
const server = new WebSocketServer({
|
noServer: true,
|
||||||
host: "127.0.0.1",
|
maxPayload: MAX_WS_MESSAGE_BYTES,
|
||||||
port: CONSOLE_PORT,
|
});
|
||||||
maxPayload: MAX_WS_MESSAGE_BYTES,
|
|
||||||
|
// One bad socket must not take the server down.
|
||||||
|
server.on("error", (error) => {
|
||||||
|
sshLogger.error("Docker console WebSocket server error", error, {
|
||||||
|
operation: "wss_error",
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
server.on("connection", handleConnection);
|
||||||
|
|
||||||
const onStartupError = (error: Error) => {
|
wss = server;
|
||||||
server.off("listening", onListening);
|
return Promise.resolve();
|
||||||
wss = null;
|
}
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onListening = () => {
|
/**
|
||||||
server.off("error", onStartupError);
|
* Hands one upgrade to the console server.
|
||||||
// Past startup an error is logged rather than fatal: one bad socket
|
*
|
||||||
// must not take the server down.
|
* Registered as a public ctx.ws route because onConsoleConnection does its own
|
||||||
server.on("error", (error) => {
|
* per-message authentication, which is where the container and host checks
|
||||||
sshLogger.error("Docker console WebSocket server error", error, {
|
* live too.
|
||||||
operation: "wss_error",
|
*/
|
||||||
});
|
export function handleConsoleUpgrade(
|
||||||
});
|
request: import("http").IncomingMessage,
|
||||||
resolve();
|
socket: import("stream").Duplex,
|
||||||
};
|
head: Buffer,
|
||||||
|
): void {
|
||||||
server.once("error", onStartupError);
|
const server = wss;
|
||||||
server.once("listening", onListening);
|
if (!server) {
|
||||||
server.on("connection", handleConnection);
|
socket.destroy();
|
||||||
|
return;
|
||||||
wss = server;
|
}
|
||||||
|
server.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
server.emit("connection", ws, request);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,8 +876,8 @@ async function onConsoleConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes every live console session and the WebSocket server, freeing port
|
* Closes every live console session and the WebSocket server. Called from the
|
||||||
* 30009. Called from the plugin's deactivate().
|
* plugin's deactivate().
|
||||||
*
|
*
|
||||||
* There is deliberately no process signal handler here. Core owns shutdown:
|
* There is deliberately no process signal handler here. Core owns shutdown:
|
||||||
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
|
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
|
||||||
@@ -894,5 +900,12 @@ export function closeConsoleServer(): Promise<void> {
|
|||||||
if (!server) return Promise.resolve();
|
if (!server) return Promise.resolve();
|
||||||
|
|
||||||
server.off("connection", handleConnection);
|
server.off("connection", handleConnection);
|
||||||
|
for (const client of server.clients) {
|
||||||
|
try {
|
||||||
|
client.close(1001, "Docker plugin disabled");
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
return new Promise((resolve) => server.close(() => resolve()));
|
return new Promise((resolve) => server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { createServer, type Server } from "node:http";
|
import express, { type Router } from "express";
|
||||||
import express from "express";
|
|
||||||
import cookieParser from "cookie-parser";
|
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
|
|
||||||
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
|
|
||||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||||
@@ -17,25 +13,21 @@ import {
|
|||||||
getRequestUserId,
|
getRequestUserId,
|
||||||
registerDockerSshRoutes,
|
registerDockerSshRoutes,
|
||||||
} from "./routes.js";
|
} from "./routes.js";
|
||||||
import { startConsoleServer, closeConsoleServer } from "./console.js";
|
import {
|
||||||
|
closeConsoleServer,
|
||||||
|
handleConsoleUpgrade,
|
||||||
|
startConsoleServer,
|
||||||
|
} from "./console.js";
|
||||||
|
|
||||||
const PORT = 30007;
|
|
||||||
|
|
||||||
let httpServer: Server | null = null;
|
|
||||||
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
|
// An express app rather than the router directly, so the existing
|
||||||
|
// register*Routes helpers keep the Application they expect. Core mounts it
|
||||||
|
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
|
||||||
|
// parsing in front of it.
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set("trust proxy", "loopback");
|
|
||||||
app.use(createCompressionMiddleware());
|
|
||||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
authManagerInstance = AuthManager.getInstance();
|
|
||||||
app.use(authManagerInstance.createAuthMiddleware());
|
|
||||||
app.use(express.json({ limit: "100mb" }));
|
|
||||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
next();
|
next();
|
||||||
@@ -50,16 +42,9 @@ export async function activate(ctx: PluginContext) {
|
|||||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = createServer(app);
|
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(PORT, "127.0.0.1", () => {
|
|
||||||
server.off("error", reject);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
httpServer = server;
|
|
||||||
|
|
||||||
|
authManagerInstance = AuthManager.getInstance();
|
||||||
try {
|
try {
|
||||||
await authManagerInstance.initialize();
|
await authManagerInstance.initialize();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -68,21 +53,27 @@ export async function activate(ctx: PluginContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The console server is started here rather than on import so disabling the
|
|
||||||
// plugin frees port 30009 and re-enabling can take it again.
|
|
||||||
await startConsoleServer();
|
await startConsoleServer();
|
||||||
|
|
||||||
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
|
// Public because the console authenticates each session over the socket
|
||||||
|
// itself, where the container and host checks also live.
|
||||||
|
ctx.ws.upgrade(
|
||||||
|
"/console",
|
||||||
|
(request, socket, head) =>
|
||||||
|
handleConsoleUpgrade(
|
||||||
|
request as Parameters<typeof handleConsoleUpgrade>[0],
|
||||||
|
socket as Parameters<typeof handleConsoleUpgrade>[1],
|
||||||
|
head as Buffer,
|
||||||
|
),
|
||||||
|
{ public: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.log.info(
|
||||||
|
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
await closeConsoleServer();
|
await closeConsoleServer();
|
||||||
|
|
||||||
if (httpServer) {
|
|
||||||
const server = httpServer;
|
|
||||||
httpServer = null;
|
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
||||||
}
|
|
||||||
|
|
||||||
authManagerInstance = null;
|
authManagerInstance = null;
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { Card, CardContent } from "@/components/card.tsx";
|
import { Card, CardContent } from "@/components/card.tsx";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||||
import {
|
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||||
resolveConnectionOrigin,
|
|
||||||
buildOriginWsUrl,
|
|
||||||
} from "@/lib/connection-origin.ts";
|
|
||||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { SSHHost } from "@/types";
|
import type { SSHHost } from "@/types";
|
||||||
@@ -299,40 +296,21 @@ function ConsoleTerminalInner({
|
|||||||
|
|
||||||
const isElectronApp = isElectron();
|
const isElectronApp = isElectron();
|
||||||
|
|
||||||
const isDev =
|
const origin = isElectronApp
|
||||||
!isElectronApp &&
|
? await resolveConnectionOrigin({
|
||||||
process.env.NODE_ENV === "development" &&
|
connectionType: "ssh",
|
||||||
(window.location.port === "3000" ||
|
connectionOrigin: hostConfig.connectionOrigin,
|
||||||
window.location.port === "5173" ||
|
})
|
||||||
window.location.port === "");
|
: "local";
|
||||||
|
|
||||||
let baseWsUrl: string;
|
const resolvedUrl = await pluginWsUrl("docker", "/console", { origin });
|
||||||
let wsProtocols: string[] = [];
|
if (!resolvedUrl) {
|
||||||
if (isDev) {
|
setIsConnecting(false);
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
toast.error(t("errors.remoteServerRequired"));
|
||||||
} else if (isElectronApp) {
|
return;
|
||||||
const origin = await resolveConnectionOrigin({
|
|
||||||
connectionType: "ssh",
|
|
||||||
connectionOrigin: hostConfig.connectionOrigin,
|
|
||||||
});
|
|
||||||
const resolvedUrl = await buildOriginWsUrl({
|
|
||||||
origin,
|
|
||||||
localPort: 30009,
|
|
||||||
localPath: "/docker/console/",
|
|
||||||
remotePath: "/docker/console/",
|
|
||||||
});
|
|
||||||
if (!resolvedUrl) {
|
|
||||||
setIsConnecting(false);
|
|
||||||
toast.error(t("errors.remoteServerRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
baseWsUrl = resolvedUrl.url;
|
|
||||||
wsProtocols = resolvedUrl.protocols;
|
|
||||||
} else {
|
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
const ws = new WebSocket(resolvedUrl.url, resolvedUrl.protocols);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const cols = terminal.cols || 80;
|
const cols = terminal.cols || 80;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"credentials:use",
|
"credentials:use",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"db:own",
|
"db:own",
|
||||||
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startFleetsService, stopFleetsService } from "./routes.js";
|
import { startFleetsService, stopFleetsService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startFleetsService();
|
startFleetsService(ctx.http.router());
|
||||||
ctx.log.info("Fleets router registered at /fleets");
|
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response, type Router } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import type { Client, SFTPWrapper } from "ssh2";
|
import type { Client, SFTPWrapper } from "ssh2";
|
||||||
@@ -40,10 +40,6 @@ import {
|
|||||||
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||||
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||||
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
|
import { resolveSnippetCommand } from "../../../../src/backend/database/routes/snippets-execution.js";
|
||||||
import {
|
|
||||||
registerFleetsRouter,
|
|
||||||
unregisterFleetsRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/fleet-dispatch.js";
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -1505,11 +1501,11 @@ export function buildRemoveCommand(
|
|||||||
|
|
||||||
/** Called from activate(). Mounts this router at /fleets via the shared
|
/** Called from activate(). Mounts this router at /fleets via the shared
|
||||||
* dispatcher. */
|
* dispatcher. */
|
||||||
export function startFleetsService(): void {
|
export function startFleetsService(mountOn: Router): void {
|
||||||
registerFleetsRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */
|
/** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */
|
||||||
export function stopFleetsService(): void {
|
export function stopFleetsService(): void {
|
||||||
unregisterFleetsRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
import type { Server } from "node:http";
|
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { PORT, shutdown, startHostMetricsService } from "./routes.js";
|
import {
|
||||||
|
onHostDeleted,
|
||||||
let httpServer: Server | null = null;
|
onHostLogin,
|
||||||
|
onHostUpdated,
|
||||||
|
shutdown,
|
||||||
|
startHostMetricsService,
|
||||||
|
} from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
httpServer = startHostMetricsService();
|
await startHostMetricsService(ctx.http.router());
|
||||||
ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`);
|
|
||||||
|
// Core used to POST these to this plugin's own port with an internal auth
|
||||||
|
// token. They are events now: no port, no shared secret, and the
|
||||||
|
// subscriptions are torn down with the plugin.
|
||||||
|
ctx.events.on("host.updated", (payload) => {
|
||||||
|
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
|
||||||
|
});
|
||||||
|
ctx.events.on("host.deleted", (payload) => {
|
||||||
|
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
|
||||||
|
});
|
||||||
|
ctx.events.on("host.login", (payload) => {
|
||||||
|
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
shutdown();
|
shutdown();
|
||||||
|
|
||||||
if (httpServer) {
|
|
||||||
const server = httpServer;
|
|
||||||
httpServer = null;
|
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
|
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
|
||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
|
|
||||||
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
|
|
||||||
import cookieParser from "cookie-parser";
|
|
||||||
import { Client, type ConnectConfig } from "ssh2";
|
import { Client, type ConnectConfig } from "ssh2";
|
||||||
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
|
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
|
||||||
import {
|
import {
|
||||||
@@ -116,7 +113,6 @@ import {
|
|||||||
requestQueue,
|
requestQueue,
|
||||||
statusPollLimiter,
|
statusPollLimiter,
|
||||||
} from "../../../../src/backend/hosts/metrics-shared/state.js";
|
} from "../../../../src/backend/hosts/metrics-shared/state.js";
|
||||||
import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js";
|
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
@@ -1075,52 +1071,18 @@ function validateHostId(
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mounted onto the router core hands this plugin at /plugin-api/host-metrics,
|
||||||
|
// rather than listening on a port of its own. Compression, CORS, cookies and
|
||||||
|
// body parsing all run in core's stack in front of this, so only the
|
||||||
|
// cache header and the routes themselves stay here.
|
||||||
export const app = express();
|
export const app = express();
|
||||||
app.set("trust proxy", "loopback");
|
app.set("trust proxy", "loopback");
|
||||||
app.use(createCompressionMiddleware());
|
|
||||||
app.use(createCorsMiddleware());
|
|
||||||
app.use(cookieParser());
|
|
||||||
app.use(express.json({ limit: "1mb" }));
|
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Internal endpoint — only accepts calls from localhost. Registered before
|
// Core authenticates every /plugin-api request before it reaches this app.
|
||||||
// the auth middleware since it's a service-to-service call authenticated by
|
|
||||||
// IP + shared secret, not a user JWT.
|
|
||||||
// Used by the main backend to notify the metrics service of SSH login events.
|
|
||||||
app.post("/internal/login-alert", async (req, res) => {
|
|
||||||
const remoteIp = req.socket.remoteAddress;
|
|
||||||
if (
|
|
||||||
remoteIp !== "127.0.0.1" &&
|
|
||||||
remoteIp !== "::1" &&
|
|
||||||
remoteIp !== "::ffff:127.0.0.1"
|
|
||||||
) {
|
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
|
||||||
}
|
|
||||||
const systemCrypto = (
|
|
||||||
await import("../../../../src/backend/utils/system-crypto.js")
|
|
||||||
).SystemCrypto;
|
|
||||||
const expectedToken = await systemCrypto.getInstance().getInternalAuthToken();
|
|
||||||
const token = req.headers["x-internal-auth"];
|
|
||||||
if (!token || token !== expectedToken) {
|
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
|
||||||
}
|
|
||||||
const { hostId, userId, sshUser, fromIp } = req.body as {
|
|
||||||
hostId: number;
|
|
||||||
userId: string;
|
|
||||||
sshUser: string;
|
|
||||||
fromIp: string;
|
|
||||||
};
|
|
||||||
notifyAutomationInternalEvent("user_login", userId, hostId, {
|
|
||||||
sshUser,
|
|
||||||
fromIp,
|
|
||||||
});
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(authManager.createAuthMiddleware());
|
|
||||||
const requireAdmin = authManager.createAdminMiddleware();
|
const requireAdmin = authManager.createAdminMiddleware();
|
||||||
|
|
||||||
async function fetchAllHosts(
|
async function fetchAllHosts(
|
||||||
@@ -2163,124 +2125,68 @@ app.post("/refresh", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* Reacts to a host being created or updated somewhere in core.
|
||||||
* /host-updated:
|
*
|
||||||
* post:
|
* Used to be POST /host-updated on this plugin's own port. An event now, so
|
||||||
* summary: Start polling for updated host
|
* there is no service-to-service HTTP call and no port. The acting user comes
|
||||||
* description: Starts polling for a specific host after it has been updated.
|
* with the event because re-reading a host needs their data key.
|
||||||
* tags:
|
|
||||||
* - Host Metrics
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* hostId:
|
|
||||||
* type: integer
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Host polling started.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid hostId.
|
|
||||||
* 401:
|
|
||||||
* description: Session expired - please log in again.
|
|
||||||
* 404:
|
|
||||||
* description: Host not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to start polling.
|
|
||||||
*/
|
*/
|
||||||
app.post("/host-updated", async (req, res) => {
|
export async function onHostUpdated(payload: {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
hostId: number;
|
||||||
const { hostId } = req.body;
|
userId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { hostId, userId } = payload;
|
||||||
|
if (!hostId || !userId) return;
|
||||||
|
|
||||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
// No data key means that user is not unlocked on this process, so there is
|
||||||
return res.status(401).json({
|
// nothing to decrypt the host with. Polling resumes when they next log in.
|
||||||
error: "Session expired - please log in again",
|
if (DataCrypto.getUserDataKey(userId) === null) return;
|
||||||
code: "SESSION_EXPIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hostId || typeof hostId !== "number") {
|
|
||||||
return res.status(400).json({ error: "Invalid hostId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hostPollCache.invalidate(hostId);
|
hostPollCache.invalidate(hostId);
|
||||||
const host = await fetchHostById(hostId, userId);
|
const host = await fetchHostById(hostId, userId);
|
||||||
if (host) {
|
if (!host) return;
|
||||||
connectionPool.clearKeyConnections(getPoolKey(host));
|
|
||||||
|
|
||||||
await pollingManager.startPollingForHost(host);
|
connectionPool.clearKeyConnections(getPoolKey(host));
|
||||||
res.json({ message: "Host polling started" });
|
await pollingManager.startPollingForHost(host);
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: "Host not found" });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statsLogger.error("Failed to start polling for host", error, {
|
statsLogger.error("Failed to start polling for host", error, {
|
||||||
operation: "host_updated",
|
operation: "host_updated",
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Failed to start polling" });
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
/** Reacts to a host being deleted. Was POST /host-deleted. */
|
||||||
* @openapi
|
export function onHostDeleted(payload: { hostId: number }): void {
|
||||||
* /host-deleted:
|
const { hostId } = payload;
|
||||||
* post:
|
if (!hostId) return;
|
||||||
* summary: Stop polling for deleted host
|
|
||||||
* description: Stops polling for a specific host after it has been deleted.
|
|
||||||
* tags:
|
|
||||||
* - Host Metrics
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* hostId:
|
|
||||||
* type: integer
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Host polling stopped.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid hostId.
|
|
||||||
* 401:
|
|
||||||
* description: Session expired - please log in again.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to stop polling.
|
|
||||||
*/
|
|
||||||
app.post("/host-deleted", async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const { hostId } = req.body;
|
|
||||||
|
|
||||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: "Session expired - please log in again",
|
|
||||||
code: "SESSION_EXPIRED",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hostId || typeof hostId !== "number") {
|
|
||||||
return res.status(400).json({ error: "Invalid hostId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pollingManager.stopPollingForHost(hostId, true);
|
pollingManager.stopPollingForHost(hostId, true);
|
||||||
res.json({ message: "Host polling stopped" });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statsLogger.error("Failed to stop polling for host", error, {
|
statsLogger.error("Failed to stop polling for host", error, {
|
||||||
operation: "host_deleted",
|
operation: "host_deleted",
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: "Failed to stop polling" });
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
/** Reacts to an SSH login. Was the internal POST /internal/login-alert. */
|
||||||
|
export function onHostLogin(payload: {
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
sshUser: string;
|
||||||
|
fromIp: string;
|
||||||
|
}): void {
|
||||||
|
const { hostId, userId, sshUser, fromIp } = payload;
|
||||||
|
if (!hostId || !userId) return;
|
||||||
|
notifyAutomationInternalEvent("user_login", userId, hostId, {
|
||||||
|
sshUser,
|
||||||
|
fromIp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -3147,41 +3053,30 @@ registerManagerRoutes(app, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PORT = 30005;
|
|
||||||
|
|
||||||
let cleanupInterval: NodeJS.Timeout | undefined;
|
let cleanupInterval: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the Host Metrics HTTP service. Called from the plugin's activate()
|
* Mounts the Host Metrics routes on the router core provides. Called from the
|
||||||
* instead of running at module scope, so enabling/disabling the plugin
|
* plugin's activate(); there is no port to bind any more.
|
||||||
* actually starts and stops the listener.
|
|
||||||
*/
|
*/
|
||||||
export function startHostMetricsService(): ReturnType<
|
export async function startHostMetricsService(mountOn: Router): Promise<void> {
|
||||||
typeof listenOnServicePort
|
mountOn.use(app);
|
||||||
> {
|
|
||||||
return listenOnServicePort({
|
|
||||||
app,
|
|
||||||
port: PORT,
|
|
||||||
logger: statsLogger,
|
|
||||||
serviceName: "metrics",
|
|
||||||
onListening: async () => {
|
|
||||||
try {
|
|
||||||
await authManager.initialize();
|
|
||||||
} catch (err) {
|
|
||||||
statsLogger.error("Failed to initialize AuthManager", err, {
|
|
||||||
operation: "auth_init_error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupInterval = setInterval(
|
try {
|
||||||
() => {
|
await authManager.initialize();
|
||||||
authFailureTracker.cleanup();
|
} catch (err) {
|
||||||
pollingBackoff.cleanup();
|
statsLogger.error("Failed to initialize AuthManager", err, {
|
||||||
},
|
operation: "auth_init_error",
|
||||||
10 * 60 * 1000,
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
cleanupInterval = setInterval(
|
||||||
|
() => {
|
||||||
|
authFailureTracker.cleanup();
|
||||||
|
pollingBackoff.cleanup();
|
||||||
},
|
},
|
||||||
});
|
10 * 60 * 1000,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { runInNewContext } from "node:vm";
|
|
||||||
import ts from "typescript";
|
|
||||||
import { beforeEach, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Load the production route without starting metrics polling or SSH services.
|
|
||||||
const source = readFileSync(
|
|
||||||
new URL("../../src/backend/routes.ts", import.meta.url),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const ast = ts.createSourceFile(
|
|
||||||
"routes.ts",
|
|
||||||
source,
|
|
||||||
ts.ScriptTarget.Latest,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const route = ast.statements.find(
|
|
||||||
(node) =>
|
|
||||||
ts.isExpressionStatement(node) &&
|
|
||||||
ts.isCallExpression(node.expression) &&
|
|
||||||
node.expression.expression.getText(ast) === "app.post" &&
|
|
||||||
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
|
|
||||||
);
|
|
||||||
if (!route) throw new Error("Login event route is missing");
|
|
||||||
const notify = vi.fn();
|
|
||||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
|
||||||
runInNewContext(
|
|
||||||
ts.transpileModule(route.getText(ast), {
|
|
||||||
compilerOptions: {
|
|
||||||
module: ts.ModuleKind.CommonJS,
|
|
||||||
target: ts.ScriptTarget.ES2022,
|
|
||||||
},
|
|
||||||
}).outputText,
|
|
||||||
{
|
|
||||||
app: {
|
|
||||||
post: (_path: string, callback: typeof handler) => {
|
|
||||||
handler = callback;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
require: () => ({
|
|
||||||
SystemCrypto: {
|
|
||||||
getInstance: () => ({
|
|
||||||
getInternalAuthToken: async () => "internal-test-token",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
notifyAutomationInternalEvent: notify,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const body = {
|
|
||||||
hostId: 42,
|
|
||||||
userId: "owner",
|
|
||||||
sshUser: "alice",
|
|
||||||
fromIp: "192.0.2.4",
|
|
||||||
};
|
|
||||||
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it("delivers successful SSH login details to automations", async () => {
|
|
||||||
const res = response();
|
|
||||||
await handler(
|
|
||||||
{
|
|
||||||
socket: { remoteAddress: "127.0.0.1" },
|
|
||||||
headers: { "x-internal-auth": "internal-test-token" },
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
|
|
||||||
sshUser: "alice",
|
|
||||||
fromIp: "192.0.2.4",
|
|
||||||
});
|
|
||||||
expect(res.json).toHaveBeenCalledWith({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["192.0.2.9", "internal-test-token"],
|
|
||||||
["127.0.0.1", "incorrect"],
|
|
||||||
])(
|
|
||||||
"rejects untrusted login notifications (%s)",
|
|
||||||
async (remoteAddress, token) => {
|
|
||||||
const res = response();
|
|
||||||
await handler(
|
|
||||||
{
|
|
||||||
socket: { remoteAddress },
|
|
||||||
headers: { "x-internal-auth": token },
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
|
||||||
expect(notify).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// Regression guard for: the /internal/login-alert route was registered
|
|
||||||
// after the global JWT auth middleware, so every service-to-service login
|
|
||||||
// alert got rejected with 401 before the route's own IP+token check ever
|
|
||||||
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
|
|
||||||
// polling managers, etc.) just to hit this one route is out of scope, so
|
|
||||||
// this asserts the registration order directly against the source instead.
|
|
||||||
describe("metrics service /internal/login-alert route order", () => {
|
|
||||||
it("is registered before the global auth middleware", () => {
|
|
||||||
const source = fs.readFileSync(
|
|
||||||
path.resolve(__dirname, "../../src/backend/routes.ts"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
|
|
||||||
const authMiddlewareIndex = source.indexOf(
|
|
||||||
"app.use(authManager.createAuthMiddleware())",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(routeIndex).toBeGreaterThan(-1);
|
|
||||||
expect(authMiddlewareIndex).toBeGreaterThan(-1);
|
|
||||||
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* SSH login alerts reach automations.
|
||||||
|
*
|
||||||
|
* This used to be POST /internal/login-alert on this plugin's own port,
|
||||||
|
* guarded by a localhost IP check plus a shared internal token, and the tests
|
||||||
|
* here covered both of those plus the middleware ordering bug that once made
|
||||||
|
* every alert 401. A4 replaced the call with an in-process event, so the
|
||||||
|
* transport, the shared secret and the ordering hazard are all gone; what is
|
||||||
|
* left to assert is that the handler forwards the right thing and ignores a
|
||||||
|
* payload it cannot use.
|
||||||
|
*
|
||||||
|
* The route module pulls in the whole metrics stack, so this extracts the
|
||||||
|
* handler from source rather than importing it, the same way the old test did.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../../src/backend/routes.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const ast = ts.createSourceFile(
|
||||||
|
"routes.ts",
|
||||||
|
source,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaration = ast.statements.find(
|
||||||
|
(node) =>
|
||||||
|
ts.isFunctionDeclaration(node) && node.name?.getText(ast) === "onHostLogin",
|
||||||
|
);
|
||||||
|
if (!declaration) throw new Error("onHostLogin is missing");
|
||||||
|
|
||||||
|
const notify = vi.fn();
|
||||||
|
const context: Record<string, unknown> = {
|
||||||
|
notifyAutomationInternalEvent: notify,
|
||||||
|
exports: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
runInNewContext(
|
||||||
|
ts.transpileModule(declaration.getText(ast), {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
}).outputText,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onHostLogin = context.onHostLogin as (payload: {
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
sshUser: string;
|
||||||
|
fromIp: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("delivers successful SSH login details to automations", () => {
|
||||||
|
onHostLogin({
|
||||||
|
hostId: 42,
|
||||||
|
userId: "owner",
|
||||||
|
sshUser: "alice",
|
||||||
|
fromIp: "192.0.2.4",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
|
||||||
|
sshUser: "alice",
|
||||||
|
fromIp: "192.0.2.4",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["no host", { hostId: 0, userId: "owner", sshUser: "a", fromIp: "1.2.3.4" }],
|
||||||
|
["no user", { hostId: 42, userId: "", sshUser: "a", fromIp: "1.2.3.4" }],
|
||||||
|
])("ignores an unusable payload (%s)", (_label, payload) => {
|
||||||
|
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
|
||||||
|
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"termix": ">=2.9.0",
|
"termix": ">=2.9.0",
|
||||||
"api": "1"
|
"api": "1"
|
||||||
},
|
},
|
||||||
"capabilities": ["db:own", "ui:surface"],
|
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"tabs": [
|
"tabs": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
} from "./routes.js";
|
} from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startNetworkTopologyService();
|
startNetworkTopologyService(ctx.http.router());
|
||||||
ctx.log.info("Network Topology router registered at /network-topology");
|
ctx.log.info(
|
||||||
|
"Network Topology routes mounted at /plugin-api/network-topology",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js";
|
import { createCurrentNetworkTopologyRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import {
|
|
||||||
registerNetworkTopologyRouter,
|
|
||||||
unregisterNetworkTopologyRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/network-topology-dispatch.js";
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -179,10 +175,10 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function startNetworkTopologyService(): void {
|
export function startNetworkTopologyService(mountOn: Router): void {
|
||||||
registerNetworkTopologyRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopNetworkTopologyService(): void {
|
export function stopNetworkTopologyService(): void {
|
||||||
unregisterNetworkTopologyRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"credentials:use",
|
"credentials:use",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"db:own",
|
"db:own",
|
||||||
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startProxmoxService, stopProxmoxService } from "./routes.js";
|
import { startProxmoxService, stopProxmoxService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startProxmoxService();
|
startProxmoxService(ctx.http.router());
|
||||||
ctx.log.info("Proxmox router registered at /proxmox");
|
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||||
@@ -19,10 +19,6 @@ import {
|
|||||||
} from "./proxmox-jump-hosts.js";
|
} from "./proxmox-jump-hosts.js";
|
||||||
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
|
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
|
||||||
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import {
|
|
||||||
registerProxmoxRouter,
|
|
||||||
unregisterProxmoxRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/proxmox-dispatch.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const proxmoxLogger = logger;
|
const proxmoxLogger = logger;
|
||||||
@@ -1139,8 +1135,8 @@ router.post(
|
|||||||
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
|
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
|
||||||
* a one-off 30s-delayed startup run, both unref'd so they never keep the
|
* a one-off 30s-delayed startup run, both unref'd so they never keep the
|
||||||
* process alive on their own). */
|
* process alive on their own). */
|
||||||
export function startProxmoxService(): void {
|
export function startProxmoxService(mountOn: Router): void {
|
||||||
registerProxmoxRouter(router);
|
mountOn.use(router);
|
||||||
proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
|
proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000);
|
||||||
proxmoxAutoSyncTimer.unref?.();
|
proxmoxAutoSyncTimer.unref?.();
|
||||||
proxmoxAutoSyncStartupTimer = setTimeout(runDueProxmoxAutoSyncs, 30 * 1000);
|
proxmoxAutoSyncStartupTimer = setTimeout(runDueProxmoxAutoSyncs, 30 * 1000);
|
||||||
@@ -1150,7 +1146,7 @@ export function startProxmoxService(): void {
|
|||||||
/** Called from deactivate(). /proxmox/* falls back to 404 until reactivated,
|
/** Called from deactivate(). /proxmox/* falls back to 404 until reactivated,
|
||||||
* and the auto-sync timers are cleared so a disabled plugin stops syncing. */
|
* and the auto-sync timers are cleared so a disabled plugin stops syncing. */
|
||||||
export function stopProxmoxService(): void {
|
export function stopProxmoxService(): void {
|
||||||
unregisterProxmoxRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
if (proxmoxAutoSyncTimer) clearInterval(proxmoxAutoSyncTimer);
|
if (proxmoxAutoSyncTimer) clearInterval(proxmoxAutoSyncTimer);
|
||||||
if (proxmoxAutoSyncStartupTimer) clearTimeout(proxmoxAutoSyncStartupTimer);
|
if (proxmoxAutoSyncStartupTimer) clearTimeout(proxmoxAutoSyncStartupTimer);
|
||||||
proxmoxAutoSyncTimer = undefined;
|
proxmoxAutoSyncTimer = undefined;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ function readGuacdOptions(): { host: string; port: number } {
|
|||||||
return resolveGuacdOptions(dbUrl);
|
return resolveGuacdOptions(dbUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GUAC_WS_PORT = 30008;
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
const GUACAMOLE_RECORDINGS_DIR =
|
const GUACAMOLE_RECORDINGS_DIR =
|
||||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||||
@@ -121,9 +120,12 @@ async function persistGuacamoleRecording(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noServer: core owns the listening socket. guacamole-lite passes this object
|
||||||
|
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
|
||||||
|
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
|
||||||
|
// guacamole port any more.
|
||||||
const websocketOptions = {
|
const websocketOptions = {
|
||||||
host: "127.0.0.1",
|
noServer: true,
|
||||||
port: GUAC_WS_PORT,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clientOptions = {
|
const clientOptions = {
|
||||||
@@ -253,13 +255,48 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
let guacServer: GuacamoleLite | null = null;
|
let guacServer: GuacamoleLite | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the guacamole-lite WebSocket server. Called from this plugin's
|
* Builds the guacamole-lite server. Called from this plugin's activate().
|
||||||
* activate() -- see plugins/remote-desktop/backend/index.mjs.
|
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
|
||||||
*/
|
*/
|
||||||
export async function startGuacamoleService(): Promise<void> {
|
export async function startGuacamoleService(): Promise<void> {
|
||||||
guacServer = createGuacServer();
|
guacServer = createGuacServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands one upgrade to guacamole-lite's own WebSocketServer.
|
||||||
|
*
|
||||||
|
* guacamole-lite constructs that server internally and has no "attach to this
|
||||||
|
* http server" option, so giving it the upgrade is the only way in. Core has
|
||||||
|
* already authenticated the request by the time this runs.
|
||||||
|
*/
|
||||||
|
export function handleGuacamoleUpgrade(
|
||||||
|
request: import("http").IncomingMessage,
|
||||||
|
socket: import("stream").Duplex,
|
||||||
|
head: Buffer,
|
||||||
|
): void {
|
||||||
|
const server = guacServer as unknown as {
|
||||||
|
webSocketServer?: {
|
||||||
|
handleUpgrade: (
|
||||||
|
request: import("http").IncomingMessage,
|
||||||
|
socket: import("stream").Duplex,
|
||||||
|
head: Buffer,
|
||||||
|
done: (ws: unknown) => void,
|
||||||
|
) => void;
|
||||||
|
emit: (event: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const wss = server?.webSocketServer;
|
||||||
|
if (!wss) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the guacamole-lite WebSocket server. Called from this plugin's
|
* Stops the guacamole-lite WebSocket server. Called from this plugin's
|
||||||
* deactivate().
|
* deactivate().
|
||||||
|
|||||||
@@ -1,33 +1,51 @@
|
|||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import {
|
import {
|
||||||
GUAC_WS_PORT,
|
|
||||||
getGuacSessionInfo,
|
getGuacSessionInfo,
|
||||||
|
handleGuacamoleUpgrade,
|
||||||
restartGuacServer,
|
restartGuacServer,
|
||||||
startGuacamoleService,
|
startGuacamoleService,
|
||||||
stopGuacamoleService,
|
stopGuacamoleService,
|
||||||
tokenService,
|
tokenService,
|
||||||
} from "./guacamole-server.js";
|
} from "./guacamole-server.js";
|
||||||
import { router } from "./routes.js";
|
import { startRemoteDesktopService } from "./routes.js";
|
||||||
import {
|
|
||||||
registerGuacamoleBridge,
|
|
||||||
unregisterGuacamoleBridge,
|
|
||||||
} from "../../../../src/backend/database/routes/guacamole-dispatch.js";
|
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
await startGuacamoleService();
|
await startGuacamoleService();
|
||||||
|
|
||||||
registerGuacamoleBridge({
|
startRemoteDesktopService(ctx.http.router());
|
||||||
router,
|
|
||||||
|
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
|
||||||
|
//
|
||||||
|
// Public because a Guacamole display authenticates with the encrypted,
|
||||||
|
// single-use connection token minted by POST /token, not with a session
|
||||||
|
// JWT. guacamole-lite decrypts and validates that token itself, and the
|
||||||
|
// route that mints one is authenticated normally.
|
||||||
|
ctx.ws.upgrade(
|
||||||
|
"/display",
|
||||||
|
(request, socket, head) =>
|
||||||
|
handleGuacamoleUpgrade(
|
||||||
|
request as Parameters<typeof handleGuacamoleUpgrade>[0],
|
||||||
|
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
|
||||||
|
head as Buffer,
|
||||||
|
),
|
||||||
|
{ public: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// What collab, session-sharing and the admin guacd setting need from a
|
||||||
|
// running Guacamole server. Revoked automatically on deactivate, and
|
||||||
|
// hosts/guacamole-sessions.ts degrades safely while it is gone.
|
||||||
|
ctx.registry.provide("remote-desktop.sessions", {
|
||||||
restart: restartGuacServer,
|
restart: restartGuacServer,
|
||||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||||
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
|
||||||
getSessionInfo: getGuacSessionInfo,
|
getSessionInfo: getGuacSessionInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.log.info(`Remote Desktop listening on ${GUAC_WS_PORT ?? 30008}`);
|
ctx.log.info(
|
||||||
|
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
unregisterGuacamoleBridge();
|
|
||||||
await stopGuacamoleService();
|
await stopGuacamoleService();
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import { GuacamoleTokenService } from "./token-service.js";
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
import { withRecordingSettings } from "./recording-settings.js";
|
import { withRecordingSettings } from "./recording-settings.js";
|
||||||
import { withDriveSettings } from "./drive-settings.js";
|
import { withDriveSettings } from "./drive-settings.js";
|
||||||
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
import { guacLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
|
||||||
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
|
||||||
import {
|
import {
|
||||||
resolveRecipientSharedHostAuthentication,
|
resolveRecipientSharedHostAuthentication,
|
||||||
@@ -37,10 +36,10 @@ import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js";
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
const authManager = AuthManager.getInstance();
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
|
|
||||||
router.use(authManager.createAuthMiddleware());
|
// Core authenticates every /plugin-api route before it reaches here, so the
|
||||||
|
// router no longer applies its own middleware.
|
||||||
|
|
||||||
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
|
router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => {
|
||||||
if (!req.userId)
|
if (!req.userId)
|
||||||
@@ -816,7 +815,7 @@ router.get("/status", async (req, res) => {
|
|||||||
status: isConnected ? "connected" : "disconnected",
|
status: isConnected ? "connected" : "disconnected",
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
port: 30008,
|
path: "/plugin-ws/remote-desktop/display",
|
||||||
status: "running",
|
status: "running",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -829,3 +828,7 @@ router.get("/status", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export { router };
|
export { router };
|
||||||
|
|
||||||
|
export function startRemoteDesktopService(mountOn: Router): void {
|
||||||
|
mountOn.use(router);
|
||||||
|
}
|
||||||
@@ -285,11 +285,13 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
let wsBase: string | null;
|
let wsBase: string | null;
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
|
// includeJwt stays off: a display authenticates with the encrypted
|
||||||
|
// connection token in the query, not a session.
|
||||||
const target = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin,
|
origin,
|
||||||
localPort: 30008,
|
localPort: 30001,
|
||||||
localPath: "/guacamole/websocket/",
|
localPath: "/plugin-ws/remote-desktop/display",
|
||||||
remotePath: "/guacamole/websocket/",
|
remotePath: "/plugin-ws/remote-desktop/display",
|
||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -299,7 +301,6 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
wsBase = target.url;
|
wsBase = target.url;
|
||||||
} else {
|
} else {
|
||||||
wsBase = buildGuacamoleWebSocketBaseUrl({
|
wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev,
|
|
||||||
isElectronApp: false,
|
isElectronApp: false,
|
||||||
isEmbeddedApp: false,
|
isEmbeddedApp: false,
|
||||||
basePath: getBasePath(),
|
basePath: getBasePath(),
|
||||||
|
|||||||
@@ -174,17 +174,20 @@ export async function getGuacamoleToken(
|
|||||||
try {
|
try {
|
||||||
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||||
|
|
||||||
const response = await guacamoleApi(origin).post("/guacamole/token", {
|
const response = await guacamoleApi(origin).post(
|
||||||
type: request.protocol,
|
"/plugin-api/remote-desktop/token",
|
||||||
hostname: request.hostname,
|
{
|
||||||
port: request.port,
|
type: request.protocol,
|
||||||
username: request.username,
|
hostname: request.hostname,
|
||||||
password: request.password,
|
port: request.port,
|
||||||
domain: request.domain,
|
username: request.username,
|
||||||
security: request.security,
|
password: request.password,
|
||||||
"ignore-cert": request.ignoreCert,
|
domain: request.domain,
|
||||||
...guacParams,
|
security: request.security,
|
||||||
});
|
"ignore-cert": request.ignoreCert,
|
||||||
|
...guacParams,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get guacamole token");
|
throw handleApiError(error, "get guacamole token");
|
||||||
@@ -214,7 +217,7 @@ export async function getGuacamoleTokenFromHost(
|
|||||||
}
|
}
|
||||||
const targetHostId = remoteHostId ?? hostId;
|
const targetHostId = remoteHostId ?? hostId;
|
||||||
const response = await guacamoleApi(origin).post(
|
const response = await guacamoleApi(origin).post(
|
||||||
`/guacamole/connect-host/${targetHostId}`,
|
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
|
||||||
{
|
{
|
||||||
...(protocol ? { protocol } : {}),
|
...(protocol ? { protocol } : {}),
|
||||||
...(promptedCredentials?.username
|
...(promptedCredentials?.username
|
||||||
@@ -237,7 +240,9 @@ export async function getGuacamoleTokenFromHost(
|
|||||||
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
|
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
|
||||||
guacd: { status: string };
|
guacd: { status: string };
|
||||||
}> {
|
}> {
|
||||||
const response = await guacamoleApi(origin).get("/guacamole/status");
|
const response = await guacamoleApi(origin).get(
|
||||||
|
"/plugin-api/remote-desktop/status",
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +252,7 @@ export async function getGuacamoleConnectionId(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const response = await guacamoleApi(origin).get(
|
const response = await guacamoleApi(origin).get(
|
||||||
`/guacamole/connection/${encodeURIComponent(connectId)}`,
|
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
return response.data.guacamoleConnectionId ?? null;
|
return response.data.guacamoleConnectionId ?? null;
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Where the Guacamole display socket lives.
|
||||||
|
*
|
||||||
|
* One path in every deployment: the plugin serves it at
|
||||||
|
* /plugin-ws/remote-desktop/display on the main backend, so there is no
|
||||||
|
* guacamole port and no separate nginx block any more. Electron with a remote
|
||||||
|
* server is the only case that targets a different host.
|
||||||
|
*/
|
||||||
export function buildGuacamoleWebSocketBaseUrl({
|
export function buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev,
|
|
||||||
isElectronApp,
|
isElectronApp,
|
||||||
isEmbeddedApp,
|
isEmbeddedApp,
|
||||||
configuredServerUrl,
|
configuredServerUrl,
|
||||||
basePath,
|
basePath,
|
||||||
location,
|
location,
|
||||||
}: {
|
}: {
|
||||||
isDev: boolean;
|
isDev?: boolean;
|
||||||
isElectronApp: boolean;
|
isElectronApp: boolean;
|
||||||
isEmbeddedApp: boolean;
|
isEmbeddedApp: boolean;
|
||||||
configuredServerUrl?: string;
|
configuredServerUrl?: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
location: Pick<Location, "protocol" | "host">;
|
location: Pick<Location, "protocol" | "host">;
|
||||||
}) {
|
}) {
|
||||||
if (isDev) return "ws://localhost:30008";
|
const route = "/plugin-ws/remote-desktop/display";
|
||||||
if (isElectronApp) {
|
|
||||||
if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008";
|
|
||||||
|
|
||||||
|
if (isElectronApp && !isEmbeddedApp && configuredServerUrl) {
|
||||||
const wsProtocol = configuredServerUrl.startsWith("https://")
|
const wsProtocol = configuredServerUrl.startsWith("https://")
|
||||||
? "wss://"
|
? "wss://"
|
||||||
: "ws://";
|
: "ws://";
|
||||||
const wsHost = configuredServerUrl
|
const wsHost = configuredServerUrl
|
||||||
.replace(/^https?:\/\//, "")
|
.replace(/^https?:\/\//, "")
|
||||||
.replace(/\/$/, "");
|
.replace(/\/$/, "");
|
||||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
return `${wsProtocol}${wsHost}${route}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isElectronApp) return `ws://127.0.0.1:30001${route}`;
|
||||||
|
|
||||||
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
|
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`;
|
return `${wsProtocol}://${location.host}${basePath}${route}`;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,9 @@ describe("guacamole API origin", () => {
|
|||||||
await getGuacdStatus("remote");
|
await getGuacdStatus("remote");
|
||||||
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
await getGuacamoleTokenFromHost(9, "remote", "vnc");
|
||||||
|
|
||||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/remote-desktop/status",
|
||||||
|
);
|
||||||
expect(authApiMock.post).toHaveBeenCalledOnce();
|
expect(authApiMock.post).toHaveBeenCalledOnce();
|
||||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||||
@@ -58,7 +60,9 @@ describe("guacamole API origin", () => {
|
|||||||
|
|
||||||
expect(status.guacd.status).toBe("connected");
|
expect(status.guacd.status).toBe("connected");
|
||||||
expect(token.token).toBe("remote-token");
|
expect(token.token).toBe("remote-token");
|
||||||
expect(remoteApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/remote-desktop/status",
|
||||||
|
);
|
||||||
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
expect(remoteApiMock.post).toHaveBeenCalledOnce();
|
||||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||||
expect(authApiMock.post).not.toHaveBeenCalled();
|
expect(authApiMock.post).not.toHaveBeenCalled();
|
||||||
@@ -81,7 +85,7 @@ describe("guacamole API origin", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
expect(remoteApiMock.post).toHaveBeenCalledWith(
|
||||||
"/guacamole/connect-host/41",
|
"/plugin-api/remote-desktop/connect-host/41",
|
||||||
{
|
{
|
||||||
protocol: "rdp",
|
protocol: "rdp",
|
||||||
promptedUsername: "admin",
|
promptedUsername: "admin",
|
||||||
@@ -101,12 +105,15 @@ describe("guacamole API origin", () => {
|
|||||||
domain: "",
|
domain: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||||
protocol: "rdp",
|
"/plugin-api/remote-desktop/connect-host/9",
|
||||||
promptedUsername: "local-admin",
|
{
|
||||||
promptedPassword: "secret",
|
protocol: "rdp",
|
||||||
promptedDomain: "",
|
promptedUsername: "local-admin",
|
||||||
});
|
promptedPassword: "secret",
|
||||||
|
promptedDomain: "",
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Support#1240: a host set to "This Device" is served by the embedded
|
// Support#1240: a host set to "This Device" is served by the embedded
|
||||||
@@ -118,9 +125,12 @@ describe("guacamole API origin", () => {
|
|||||||
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
|
||||||
|
|
||||||
expect(token.token).toBe("local-token");
|
expect(token.token).toBe("local-token");
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", {
|
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||||
protocol: "rdp",
|
"/plugin-api/remote-desktop/connect-host/9",
|
||||||
});
|
{
|
||||||
|
protocol: "rdp",
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
expect(remoteApiMock.post).not.toHaveBeenCalled();
|
||||||
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -131,7 +141,9 @@ describe("guacamole API origin", () => {
|
|||||||
const status = await getGuacdStatus("local");
|
const status = await getGuacdStatus("local");
|
||||||
|
|
||||||
expect(status.guacd.status).toBe("disconnected");
|
expect(status.guacd.status).toBe("disconnected");
|
||||||
expect(authApiMock.get).toHaveBeenCalledWith("/guacamole/status");
|
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/remote-desktop/status",
|
||||||
|
);
|
||||||
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
expect(remoteApiMock.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,7 +178,7 @@ it("looks up the session on the backend that issued its token", async () => {
|
|||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
await getGuacamoleConnectionId("connect/id", "remote", signal);
|
||||||
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
expect(remoteApiMock.get).toHaveBeenCalledWith(
|
||||||
"/guacamole/connection/connect%2Fid",
|
"/plugin-api/remote-desktop/connection/connect%2Fid",
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
expect(authApiMock.get).not.toHaveBeenCalled();
|
expect(authApiMock.get).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -6,63 +6,55 @@ const httpsLocation = {
|
|||||||
host: "termix.example.com",
|
host: "termix.example.com",
|
||||||
} as Location;
|
} as Location;
|
||||||
|
|
||||||
|
// The display socket moved off port 30008 to /plugin-ws/remote-desktop/display
|
||||||
|
// on the main backend, so every case below asserts the same one path. Dev is
|
||||||
|
// no longer a special case: Vite proxies the backend, so there is nothing to
|
||||||
|
// point at a separate port.
|
||||||
|
const ROUTE = "/plugin-ws/remote-desktop/display";
|
||||||
|
|
||||||
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
describe("buildGuacamoleWebSocketBaseUrl", () => {
|
||||||
it("uses the same origin guacamole websocket path in production web builds", () => {
|
it("uses the same origin in production web builds", () => {
|
||||||
expect(
|
expect(
|
||||||
buildGuacamoleWebSocketBaseUrl({
|
buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev: false,
|
|
||||||
isElectronApp: false,
|
isElectronApp: false,
|
||||||
isEmbeddedApp: false,
|
isEmbeddedApp: false,
|
||||||
basePath: "",
|
basePath: "",
|
||||||
location: httpsLocation,
|
location: httpsLocation,
|
||||||
}),
|
}),
|
||||||
).toBe("wss://termix.example.com/guacamole/websocket/");
|
).toBe(`wss://termix.example.com${ROUTE}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the runtime base path for proxied web deployments", () => {
|
it("preserves the runtime base path for proxied web deployments", () => {
|
||||||
expect(
|
expect(
|
||||||
buildGuacamoleWebSocketBaseUrl({
|
buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev: false,
|
|
||||||
isElectronApp: false,
|
isElectronApp: false,
|
||||||
isEmbeddedApp: false,
|
isEmbeddedApp: false,
|
||||||
basePath: "/termix",
|
basePath: "/termix",
|
||||||
location: httpsLocation,
|
location: httpsLocation,
|
||||||
}),
|
}),
|
||||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
).toBe(`wss://termix.example.com/termix${ROUTE}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps direct local websocket access for development and embedded electron", () => {
|
it("targets the embedded backend for electron running locally", () => {
|
||||||
expect(
|
expect(
|
||||||
buildGuacamoleWebSocketBaseUrl({
|
buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev: true,
|
|
||||||
isElectronApp: false,
|
|
||||||
isEmbeddedApp: false,
|
|
||||||
basePath: "/termix",
|
|
||||||
location: httpsLocation,
|
|
||||||
}),
|
|
||||||
).toBe("ws://localhost:30008");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildGuacamoleWebSocketBaseUrl({
|
|
||||||
isDev: false,
|
|
||||||
isElectronApp: true,
|
isElectronApp: true,
|
||||||
isEmbeddedApp: true,
|
isEmbeddedApp: true,
|
||||||
basePath: "/termix",
|
basePath: "/termix",
|
||||||
location: httpsLocation,
|
location: httpsLocation,
|
||||||
}),
|
}),
|
||||||
).toBe("ws://127.0.0.1:30008");
|
).toBe(`ws://127.0.0.1:30001${ROUTE}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the configured remote server URL for electron remote mode", () => {
|
it("uses the configured remote server URL for electron remote mode", () => {
|
||||||
expect(
|
expect(
|
||||||
buildGuacamoleWebSocketBaseUrl({
|
buildGuacamoleWebSocketBaseUrl({
|
||||||
isDev: false,
|
|
||||||
isElectronApp: true,
|
isElectronApp: true,
|
||||||
isEmbeddedApp: false,
|
isEmbeddedApp: false,
|
||||||
configuredServerUrl: "https://termix.example.com/termix/",
|
configuredServerUrl: "https://termix.example.com/termix/",
|
||||||
basePath: "",
|
basePath: "",
|
||||||
location: httpsLocation,
|
location: httpsLocation,
|
||||||
}),
|
}),
|
||||||
).toBe("wss://termix.example.com/termix/guacamole/websocket/");
|
).toBe(`wss://termix.example.com/termix${ROUTE}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import {
|
import {
|
||||||
TERMINAL_WS_PORT,
|
handleTerminalUpgrade,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
startTerminalServer,
|
|
||||||
stopTerminalServer,
|
stopTerminalServer,
|
||||||
} from "../../../../src/backend/hosts/terminal/index.js";
|
} from "../../../../src/backend/hosts/terminal/index.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
await startTerminalServer();
|
// Public because the terminal authenticates each socket itself: a share-link
|
||||||
|
// guest arrives with a share token instead of a session, so the check has to
|
||||||
|
// happen where that token is understood.
|
||||||
|
ctx.ws.upgrade(
|
||||||
|
"/terminal",
|
||||||
|
(request, socket, head) =>
|
||||||
|
handleTerminalUpgrade(
|
||||||
|
request as Parameters<typeof handleTerminalUpgrade>[0],
|
||||||
|
socket as Parameters<typeof handleTerminalUpgrade>[1],
|
||||||
|
head as Buffer,
|
||||||
|
),
|
||||||
|
{ public: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Published so collab and session-sharing can reach live sessions without
|
// Published so collab and session-sharing can reach live sessions without
|
||||||
// importing a plugin. Revoked automatically when this plugin is disabled.
|
// importing a plugin. Revoked automatically when this plugin is disabled.
|
||||||
ctx.registry.provide("terminal.sessions", sessionManager ?? null);
|
ctx.registry.provide("terminal.sessions", sessionManager ?? null);
|
||||||
|
|
||||||
ctx.log.info(`SSH terminal listening on ${TERMINAL_WS_PORT ?? 30002}`);
|
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
"termix": ">=2.9.0",
|
"termix": ">=2.9.0",
|
||||||
"api": "1"
|
"api": "1"
|
||||||
},
|
},
|
||||||
"capabilities": ["settings:read-core", "network:outbound", "ui:surface"],
|
"capabilities": [
|
||||||
|
"settings:read-core",
|
||||||
|
"network:outbound",
|
||||||
|
"network:serve",
|
||||||
|
"ui:surface"
|
||||||
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"permissionGroup": {
|
"permissionGroup": {
|
||||||
"group": "tailscale",
|
"group": "tailscale",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startTailscaleService, stopTailscaleService } from "./routes.js";
|
import { startTailscaleService, stopTailscaleService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startTailscaleService();
|
startTailscaleService(ctx.http.router());
|
||||||
ctx.log.info("Tailscale router registered at /tailscale");
|
ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import { PermissionManager } from "../../../../src/backend/utils/permission-mana
|
|||||||
import { apiLogger } from "../../../../src/backend/utils/logger.js";
|
import { apiLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
||||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import {
|
|
||||||
registerTailscaleRouter,
|
|
||||||
unregisterTailscaleRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/tailscale-dispatch.js";
|
|
||||||
|
|
||||||
interface TailscaleDevice {
|
interface TailscaleDevice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -152,12 +148,12 @@ router.get(
|
|||||||
|
|
||||||
/** Called from activate(). Mounts this router at /tailscale via the shared
|
/** Called from activate(). Mounts this router at /tailscale via the shared
|
||||||
* dispatcher. */
|
* dispatcher. */
|
||||||
export function startTailscaleService(): void {
|
export function startTailscaleService(mountOn: Router): void {
|
||||||
registerTailscaleRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from deactivate(). /tailscale/* falls back to 404 until
|
/** Called from deactivate(). /tailscale/* falls back to 404 until
|
||||||
* reactivated. */
|
* reactivated. */
|
||||||
export function stopTailscaleService(): void {
|
export function stopTailscaleService(): void {
|
||||||
unregisterTailscaleRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export async function getTailscaleDevices(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/tailscale/devices");
|
const response = await authApi.get("/plugin-api/tailscale/devices");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"hosts:read",
|
"hosts:read",
|
||||||
"credentials:use",
|
"credentials:use",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
|
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startWebEndpointService();
|
startWebEndpointService(ctx.http.router());
|
||||||
ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint");
|
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import express from "express";
|
import express, { type Router } from "express";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
|
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
@@ -12,10 +12,6 @@ import {
|
|||||||
} from "../../../../src/backend/hosts/tunnel/manager.js";
|
} from "../../../../src/backend/hosts/tunnel/manager.js";
|
||||||
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
|
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
|
||||||
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
|
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
|
||||||
import {
|
|
||||||
registerWebEndpointRouter,
|
|
||||||
unregisterWebEndpointRouter,
|
|
||||||
} from "../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js";
|
|
||||||
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
|
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
|
||||||
|
|
||||||
/** Matches the spec's ten minutes. */
|
/** Matches the spec's ten minutes. */
|
||||||
@@ -302,7 +298,7 @@ export const router = express.Router();
|
|||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
|
|
||||||
export function startWebEndpointService(): void {
|
export function startWebEndpointService(mountOn: Router): void {
|
||||||
if (!started) {
|
if (!started) {
|
||||||
// Built on first start rather than at module scope so importing this
|
// Built on first start rather than at module scope so importing this
|
||||||
// module for a handler unit test does not construct the auth singleton.
|
// module for a handler unit test does not construct the auth singleton.
|
||||||
@@ -315,9 +311,9 @@ export function startWebEndpointService(): void {
|
|||||||
router.post("/open", authenticateJWT, handleWebEndpointOpen);
|
router.post("/open", authenticateJWT, handleWebEndpointOpen);
|
||||||
started = true;
|
started = true;
|
||||||
}
|
}
|
||||||
registerWebEndpointRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopWebEndpointService(): void {
|
export function stopWebEndpointService(): void {
|
||||||
unregisterWebEndpointRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -305,33 +305,28 @@ describe("POST /tunnel/web-endpoint/open", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client resolves "/tunnel/web-endpoint/open" against a baseURL that
|
* The client path and the server path have to agree.
|
||||||
* already ends in /ssh, and nginx proxies /ssh through with the path intact --
|
|
||||||
* so the server must serve the FULL "/ssh/tunnel/web-endpoint/open" path.
|
|
||||||
* Serving the unprefixed form 404s every call, and no handler unit test
|
|
||||||
* notices because they call the handler directly.
|
|
||||||
*
|
*
|
||||||
* This was caught only by opening a real tunnel against a deployed build.
|
* This guards a real bug: the route once served the unprefixed form while the
|
||||||
|
* client called the full one, so every call 404d and no handler unit test
|
||||||
|
* noticed, because those call the handler directly. It was caught only by
|
||||||
|
* opening a tunnel against a deployed build.
|
||||||
*
|
*
|
||||||
* Since the plugin migration that path is composed of two halves: the mount
|
* The shape changed with A4. The route used to be composed of a mount point in
|
||||||
* point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own
|
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
|
||||||
* router. Either half drifting breaks the URL, so both are asserted here.
|
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
|
||||||
|
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
|
||||||
|
* "/open". Asserting that keeps the guard meaningful without re-encoding a
|
||||||
|
* prefix the plugin no longer controls.
|
||||||
*/
|
*/
|
||||||
describe("route registration", () => {
|
describe("route registration", () => {
|
||||||
it("registers /open on the router the dispatcher forwards to", async () => {
|
it("registers /open on the router core hands it", async () => {
|
||||||
const routes = await import("../../src/backend/routes.js");
|
const routes = await import("../../src/backend/routes.js");
|
||||||
const dispatch =
|
const express = (await import("express")).default;
|
||||||
await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js");
|
const mountOn = express.Router();
|
||||||
const registered: unknown[] = [];
|
|
||||||
vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation(
|
|
||||||
(router) => {
|
|
||||||
registered.push(router);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
routes.startWebEndpointService();
|
routes.startWebEndpointService(mountOn);
|
||||||
|
|
||||||
expect(registered).toHaveLength(1);
|
|
||||||
const paths = (
|
const paths = (
|
||||||
routes.router as unknown as {
|
routes.router as unknown as {
|
||||||
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
|
||||||
@@ -339,15 +334,17 @@ describe("route registration", () => {
|
|||||||
).stack
|
).stack
|
||||||
.filter((layer) => layer.route?.methods.post)
|
.filter((layer) => layer.route?.methods.post)
|
||||||
.map((layer) => layer.route?.path);
|
.map((layer) => layer.route?.path);
|
||||||
|
|
||||||
expect(paths).toContain("/open");
|
expect(paths).toContain("/open");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is mounted so the full client path resolves", async () => {
|
it("is reached through the plugin mount rather than a port of its own", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url),
|
new URL("../../src/backend/index.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(source).toContain('app.use("/ssh/tunnel/web-endpoint"');
|
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
|
||||||
|
expect(source).toContain("ctx.http.router()");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"termix": ">=2.9.0",
|
"termix": ">=2.9.0",
|
||||||
"api": "1"
|
"api": "1"
|
||||||
},
|
},
|
||||||
"capabilities": ["db:own", "ui:surface"],
|
"capabilities": ["db:own", "network:serve", "ui:surface"],
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"]
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
|
import { startWorkspacesService, stopWorkspacesService } from "./routes.js";
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
startWorkspacesService();
|
startWorkspacesService(ctx.http.router());
|
||||||
ctx.log.info("Workspaces router registered at /workspaces");
|
ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response, type Router } from "express";
|
||||||
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
import { databaseLogger } from "../../../../src/backend/utils/logger.js";
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||||
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
|
import type { WorkspaceRecord } from "../../../../src/backend/database/repositories/workspace-repository.js";
|
||||||
import {
|
|
||||||
registerWorkspacesRouter,
|
|
||||||
unregisterWorkspacesRouter,
|
|
||||||
} from "../../../../src/backend/database/routes/workspace-dispatch.js";
|
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
@@ -639,10 +635,10 @@ router.delete(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export function startWorkspacesService(): void {
|
export function startWorkspacesService(mountOn: Router): void {
|
||||||
registerWorkspacesRouter(router);
|
mountOn.use(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopWorkspacesService(): void {
|
export function stopWorkspacesService(): void {
|
||||||
unregisterWorkspacesRouter();
|
// Unmounted by the runtime when the plugin deactivates.
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
|||||||
|
|
||||||
export async function listWorkspaces(): Promise<Workspace[]> {
|
export async function listWorkspaces(): Promise<Workspace[]> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/workspaces");
|
const response = await authApi.get("/plugin-api/workspaces");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch workspaces");
|
throw handleApiError(error, "fetch workspaces");
|
||||||
@@ -17,7 +17,7 @@ export async function createWorkspace(data: {
|
|||||||
payload: WorkspacePayload;
|
payload: WorkspacePayload;
|
||||||
}): Promise<Workspace> {
|
}): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/workspaces", data);
|
const response = await authApi.post("/plugin-api/workspaces", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create workspace");
|
throw handleApiError(error, "create workspace");
|
||||||
@@ -29,7 +29,7 @@ export async function renameWorkspace(
|
|||||||
data: { name?: string; color?: string | null; icon?: string | null },
|
data: { name?: string; color?: string | null; icon?: string | null },
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.patch(`/workspaces/${id}`, data);
|
const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update workspace");
|
throw handleApiError(error, "update workspace");
|
||||||
@@ -41,7 +41,7 @@ export async function updateWorkspaceContent(
|
|||||||
payload: WorkspacePayload,
|
payload: WorkspacePayload,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.put(`/workspaces/${id}/content`, {
|
const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, {
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -54,7 +54,7 @@ export async function deleteWorkspace(
|
|||||||
id: number,
|
id: number,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete(`/workspaces/${id}`);
|
const response = await authApi.delete(`/plugin-api/workspaces/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete workspace");
|
throw handleApiError(error, "delete workspace");
|
||||||
@@ -66,9 +66,12 @@ export async function duplicateWorkspace(
|
|||||||
name: string,
|
name: string,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/workspaces/${id}/duplicate`, {
|
const response = await authApi.post(
|
||||||
name,
|
`/plugin-api/workspaces/${id}/duplicate`,
|
||||||
});
|
{
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "duplicate workspace");
|
throw handleApiError(error, "duplicate workspace");
|
||||||
@@ -77,7 +80,9 @@ export async function duplicateWorkspace(
|
|||||||
|
|
||||||
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/workspaces/${id}/set-default`);
|
const response = await authApi.post(
|
||||||
|
`/plugin-api/workspaces/${id}/set-default`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "set default workspace");
|
throw handleApiError(error, "set default workspace");
|
||||||
@@ -86,7 +91,9 @@ export async function setDefaultWorkspace(id: number): Promise<Workspace> {
|
|||||||
|
|
||||||
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/workspaces/${id}/unset-default`);
|
const response = await authApi.post(
|
||||||
|
`/plugin-api/workspaces/${id}/unset-default`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "unset default workspace");
|
throw handleApiError(error, "unset default workspace");
|
||||||
@@ -95,7 +102,7 @@ export async function unsetDefaultWorkspace(id: number): Promise<Workspace> {
|
|||||||
|
|
||||||
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/workspaces/${id}/apply`);
|
const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "apply workspace");
|
throw handleApiError(error, "apply workspace");
|
||||||
@@ -104,7 +111,7 @@ export async function applyWorkspaceServer(id: number): Promise<Workspace> {
|
|||||||
|
|
||||||
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
|
export async function getLastSessionWorkspace(): Promise<Workspace | null> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/workspaces/last-session");
|
const response = await authApi.get("/plugin-api/workspaces/last-session");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch last session workspace");
|
throw handleApiError(error, "fetch last session workspace");
|
||||||
@@ -115,7 +122,7 @@ export async function saveLastSessionWorkspace(
|
|||||||
payload: WorkspacePayload,
|
payload: WorkspacePayload,
|
||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.put("/workspaces/last-session", {
|
const response = await authApi.put("/plugin-api/workspaces/last-session", {
|
||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -46,73 +46,88 @@ const samplePayload: WorkspacePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("workspaces-api", () => {
|
describe("workspaces-api", () => {
|
||||||
it("listWorkspaces GETs /workspaces", async () => {
|
it("listWorkspaces GETs /plugin-api/workspaces", async () => {
|
||||||
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
|
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
|
||||||
const result = await listWorkspaces();
|
const result = await listWorkspaces();
|
||||||
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces");
|
expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces");
|
||||||
expect(result).toEqual([{ id: 1 }]);
|
expect(result).toEqual([{ id: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createWorkspace POSTs /workspaces with the payload", async () => {
|
it("createWorkspace POSTs /plugin-api/workspaces with the payload", async () => {
|
||||||
await createWorkspace({
|
await createWorkspace({
|
||||||
name: "Test A",
|
name: "Test A",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
payload: samplePayload,
|
payload: samplePayload,
|
||||||
});
|
});
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces", {
|
expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", {
|
||||||
name: "Test A",
|
name: "Test A",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
payload: samplePayload,
|
payload: samplePayload,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renameWorkspace PATCHes /workspaces/:id", async () => {
|
it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => {
|
||||||
await renameWorkspace(5, { name: "New Name" });
|
await renameWorkspace(5, { name: "New Name" });
|
||||||
expect(authApiMock.patch).toHaveBeenCalledWith("/workspaces/5", {
|
expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", {
|
||||||
name: "New Name",
|
name: "New Name",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateWorkspaceContent PUTs /workspaces/:id/content", async () => {
|
it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => {
|
||||||
await updateWorkspaceContent(5, samplePayload);
|
await updateWorkspaceContent(5, samplePayload);
|
||||||
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/5/content", {
|
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||||
payload: samplePayload,
|
"/plugin-api/workspaces/5/content",
|
||||||
});
|
{
|
||||||
|
payload: samplePayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteWorkspace DELETEs /workspaces/:id", async () => {
|
it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => {
|
||||||
await deleteWorkspace(5);
|
await deleteWorkspace(5);
|
||||||
expect(authApiMock.delete).toHaveBeenCalledWith("/workspaces/5");
|
expect(authApiMock.delete).toHaveBeenCalledWith("/plugin-api/workspaces/5");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("duplicateWorkspace POSTs /workspaces/:id/duplicate with a name", async () => {
|
it("duplicateWorkspace POSTs /plugin-api/workspaces/:id/duplicate with a name", async () => {
|
||||||
await duplicateWorkspace(5, "Test A (copy)");
|
await duplicateWorkspace(5, "Test A (copy)");
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/duplicate", {
|
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||||
name: "Test A (copy)",
|
"/plugin-api/workspaces/5/duplicate",
|
||||||
});
|
{
|
||||||
|
name: "Test A (copy)",
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setDefaultWorkspace POSTs /workspaces/:id/set-default", async () => {
|
it("setDefaultWorkspace POSTs /plugin-api/workspaces/:id/set-default", async () => {
|
||||||
await setDefaultWorkspace(5);
|
await setDefaultWorkspace(5);
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/set-default");
|
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/workspaces/5/set-default",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applyWorkspaceServer POSTs /workspaces/:id/apply", async () => {
|
it("applyWorkspaceServer POSTs /plugin-api/workspaces/:id/apply", async () => {
|
||||||
await applyWorkspaceServer(5);
|
await applyWorkspaceServer(5);
|
||||||
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/apply");
|
expect(authApiMock.post).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/workspaces/5/apply",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getLastSessionWorkspace GETs /workspaces/last-session", async () => {
|
it("getLastSessionWorkspace GETs /plugin-api/workspaces/last-session", async () => {
|
||||||
authApiMock.get.mockResolvedValueOnce({ data: null });
|
authApiMock.get.mockResolvedValueOnce({ data: null });
|
||||||
const result = await getLastSessionWorkspace();
|
const result = await getLastSessionWorkspace();
|
||||||
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces/last-session");
|
expect(authApiMock.get).toHaveBeenCalledWith(
|
||||||
|
"/plugin-api/workspaces/last-session",
|
||||||
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saveLastSessionWorkspace PUTs /workspaces/last-session", async () => {
|
it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => {
|
||||||
await saveLastSessionWorkspace(samplePayload);
|
await saveLastSessionWorkspace(samplePayload);
|
||||||
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/last-session", {
|
expect(authApiMock.put).toHaveBeenCalledWith(
|
||||||
payload: samplePayload,
|
"/plugin-api/workspaces/last-session",
|
||||||
});
|
{
|
||||||
|
payload: samplePayload,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,7 +82,6 @@
|
|||||||
"plugins/network-topology/src/backend/routes.ts",
|
"plugins/network-topology/src/backend/routes.ts",
|
||||||
"plugins/proxmox/src/backend/routes.ts",
|
"plugins/proxmox/src/backend/routes.ts",
|
||||||
"plugins/remote-desktop/src/backend/guacamole-server.ts",
|
"plugins/remote-desktop/src/backend/guacamole-server.ts",
|
||||||
"plugins/remote-desktop/src/backend/index.ts",
|
|
||||||
"plugins/remote-desktop/src/backend/routes.ts",
|
"plugins/remote-desktop/src/backend/routes.ts",
|
||||||
"plugins/remote-desktop/src/backend/token-service.ts",
|
"plugins/remote-desktop/src/backend/token-service.ts",
|
||||||
"plugins/ssh-terminal/src/backend/index.ts",
|
"plugins/ssh-terminal/src/backend/index.ts",
|
||||||
|
|||||||
@@ -10,32 +10,25 @@ import hostRoutes from "./routes/host.js";
|
|||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import fleetsDispatch from "./routes/fleet-dispatch.js";
|
|
||||||
import workspaceDispatch from "./routes/workspace-dispatch.js";
|
|
||||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
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 guacamoleDispatch from "./routes/guacamole-dispatch.js";
|
|
||||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||||
import collabRoutes from "../hosts/collab/routes.js";
|
import collabRoutes from "../hosts/collab/routes.js";
|
||||||
import networkTopologyDispatch from "./routes/network-topology-dispatch.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";
|
||||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||||
import proxmoxDispatch from "./routes/proxmox-dispatch.js";
|
|
||||||
import termixIdRoutes from "./routes/termix-id.js";
|
import termixIdRoutes from "./routes/termix-id.js";
|
||||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||||
import tailscaleDispatch from "./routes/tailscale-dispatch.js";
|
|
||||||
import vaultRoutes from "./routes/vault.js";
|
import vaultRoutes from "./routes/vault.js";
|
||||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||||
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
import notificationChannelsRoutes from "./routes/notification-channels-routes.js";
|
||||||
import aiDispatch from "./routes/ai-dispatch.js";
|
|
||||||
import automationsDispatch from "./routes/automation-dispatch.js";
|
|
||||||
import syncRoutes from "./routes/sync.js";
|
import syncRoutes from "./routes/sync.js";
|
||||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||||
|
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||||
import pluginRoutes from "./routes/plugins.js";
|
import pluginRoutes from "./routes/plugins.js";
|
||||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||||
@@ -263,8 +256,23 @@ async function fetchGitHubAPI<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(bodyParser.json({ limit: "2mb" }));
|
// Skipped for /plugin-api: a plugin router brings its own parsers with its own
|
||||||
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
|
// limit (see plugins/http.ts), and parsing here first would consume the body
|
||||||
|
// and silently cap every plugin at this limit instead.
|
||||||
|
const coreJsonParser = bodyParser.json({ limit: "2mb" });
|
||||||
|
const coreUrlencodedParser = bodyParser.urlencoded({
|
||||||
|
limit: "2mb",
|
||||||
|
extended: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith("/plugin-api/")) return next();
|
||||||
|
coreJsonParser(req, res, next);
|
||||||
|
});
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith("/plugin-api/")) return next();
|
||||||
|
coreUrlencodedParser(req, res, next);
|
||||||
|
});
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use((_req, res, next) => {
|
app.use((_req, res, next) => {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
@@ -1753,29 +1761,21 @@ app.use("/host", hostRoutes);
|
|||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/fleets", fleetsDispatch);
|
|
||||||
app.use("/workspaces", workspaceDispatch);
|
|
||||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
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", guacamoleDispatch);
|
|
||||||
app.use("/session-sharing", sessionSharingRoutes);
|
app.use("/session-sharing", sessionSharingRoutes);
|
||||||
app.use("/collab", collabRoutes);
|
app.use("/collab", collabRoutes);
|
||||||
app.use("/network-topology", networkTopologyDispatch);
|
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
app.use("/user-preferences", userPreferencesRoutes);
|
app.use("/user-preferences", userPreferencesRoutes);
|
||||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||||
app.use("/proxmox", proxmoxDispatch);
|
|
||||||
app.use("/termix-id", termixIdRoutes);
|
app.use("/termix-id", termixIdRoutes);
|
||||||
registerAuditLogRoutes(app, authenticateJWT);
|
registerAuditLogRoutes(app, authenticateJWT);
|
||||||
app.use("/tailscale", tailscaleDispatch);
|
|
||||||
app.use("/vault", vaultRoutes);
|
app.use("/vault", vaultRoutes);
|
||||||
app.use("/secret-sources", secretSourceRoutes);
|
app.use("/secret-sources", secretSourceRoutes);
|
||||||
app.use("/automations", automationsDispatch);
|
|
||||||
app.use("/ai", aiDispatch);
|
|
||||||
app.use("/", notificationChannelsRoutes);
|
app.use("/", notificationChannelsRoutes);
|
||||||
app.use("/sync", syncRoutes);
|
app.use("/sync", syncRoutes);
|
||||||
app.use("/plugins", pluginRoutes);
|
app.use("/plugins", pluginRoutes);
|
||||||
@@ -2028,6 +2028,10 @@ app.get(
|
|||||||
|
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
|
|
||||||
|
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||||
|
// left alone, so core's own upgrade handling is unaffected.
|
||||||
|
attachPluginWebSockets(httpServer);
|
||||||
|
|
||||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === "EADDRINUSE") {
|
if (err.code === "EADDRINUSE") {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
@@ -2076,6 +2080,8 @@ if (
|
|||||||
app,
|
app,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
attachPluginWebSockets(httpsServer);
|
||||||
|
|
||||||
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
|
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === "EADDRINUSE") {
|
if (err.code === "EADDRINUSE") {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// Mounted at /ai. The AI feature ships as a first-party in-process plugin
|
|
||||||
// (plugins/ai) rather than a route file imported directly here, so this
|
|
||||||
// module exists to keep /ai/* working at its original path without handing
|
|
||||||
// the plugin a reference to the shared app instance. The plugin runtime
|
|
||||||
// registers its router via registerAiRouter when it activates, and clears it
|
|
||||||
// on deactivate, so a disabled plugin falls back to the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the AI plugin's own), and every route in
|
|
||||||
// that router already applies authenticateJWT itself, the same as every other
|
|
||||||
// router mounted alongside it here (/users, /host, /snippets, ...). /plugin-api
|
|
||||||
// gates at the mount point because it forwards to arbitrary third-party plugin
|
|
||||||
// code that cannot be assumed to gate itself; that reasoning does not apply to
|
|
||||||
// a single first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerAiRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered AI plugin router", {
|
|
||||||
operation: "ai_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterAiRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered AI plugin router", {
|
|
||||||
operation: "ai_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function aiDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "The AI assistant is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// Mounted at /automations. Automations ships as a first-party in-process
|
|
||||||
// plugin (plugins/automations) rather than a route file imported directly
|
|
||||||
// here, so this module exists to keep /automations/* working at its original
|
|
||||||
// path without handing the plugin a reference to the shared app instance. The
|
|
||||||
// plugin runtime registers its router via registerAutomationsRouter when it
|
|
||||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
|
||||||
// the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Automations plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT/requireDataAccess
|
|
||||||
// itself (including the intentionally unauthenticated webhook route, which
|
|
||||||
// gates on its own token instead), the same as every other router mounted
|
|
||||||
// alongside it here (/users, /host, /fleets, ...). /plugin-api gates at the
|
|
||||||
// mount point because it forwards to arbitrary third-party plugin code that
|
|
||||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
|
||||||
// first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerAutomationsRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Automations plugin router", {
|
|
||||||
operation: "automations_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterAutomationsRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Automations plugin router", {
|
|
||||||
operation: "automations_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function automationsDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Automations is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Mounted at /fleets. Fleets ships as a first-party in-process plugin
|
|
||||||
// (plugins/fleets) rather than a route file imported directly here, so this
|
|
||||||
// module exists to keep /fleets/* working at its original path without
|
|
||||||
// handing the plugin a reference to the shared app instance. The plugin
|
|
||||||
// runtime registers its router via registerFleetsRouter when it activates,
|
|
||||||
// and clears it on deactivate, so a disabled plugin falls back to the 404
|
|
||||||
// below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Fleets plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT/requireDataAccess
|
|
||||||
// itself, the same as every other router mounted alongside it here (/users,
|
|
||||||
// /host, /snippets, ...). /plugin-api gates at the mount point because it
|
|
||||||
// forwards to arbitrary third-party plugin code that cannot be assumed to
|
|
||||||
// gate itself; that reasoning does not apply to a single first-party router
|
|
||||||
// that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerFleetsRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Fleets plugin router", {
|
|
||||||
operation: "fleets_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterFleetsRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Fleets plugin router", {
|
|
||||||
operation: "fleets_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function fleetsDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Fleets is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// Mounted at /guacamole. Remote Desktop ships as a first-party in-process
|
|
||||||
// plugin (plugins/remote-desktop) rather than a route file imported directly
|
|
||||||
// here, so this module keeps /guacamole/* working at its original path
|
|
||||||
// without handing the plugin a reference to the shared app instance.
|
|
||||||
//
|
|
||||||
// It also brokers the small surface collab, session-sharing and the admin
|
|
||||||
// settings route need from the plugin (join tokens, live-session lookups, a
|
|
||||||
// server restart hook). Those core modules cannot import the plugin's
|
|
||||||
// TypeScript directly -- plugins/ is type-checked by tsconfig.plugins.json as
|
|
||||||
// a one-directional read of src/backend/, and a
|
|
||||||
// core file under tsconfig.node.json's rootDir cannot statically import
|
|
||||||
// something outside it. So the plugin registers this bridge once on
|
|
||||||
// activate, and clears it on deactivate; a disabled/uninstalled plugin falls
|
|
||||||
// back to the safe defaults below (404 for the router, null for session
|
|
||||||
// lookups, a no-op restart).
|
|
||||||
//
|
|
||||||
// This is an accepted, temporary coupling -- see the plugin's own README.
|
|
||||||
// collab and session-sharing are expected to become plugins of their own
|
|
||||||
// later, at which point this becomes a real inter-plugin service reference
|
|
||||||
// instead of a bridge object living in core.
|
|
||||||
//
|
|
||||||
// The router forwards with no auth of its own: it delegates to exactly one
|
|
||||||
// first-party router, and every route in that router already applies its
|
|
||||||
// own auth middleware.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
export interface GuacamoleSessionInfo {
|
|
||||||
guacamoleConnectionId: string;
|
|
||||||
hostId: number;
|
|
||||||
ownerUserId: string;
|
|
||||||
protocol: string;
|
|
||||||
openedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GuacamoleBridge {
|
|
||||||
router: Router;
|
|
||||||
restart: () => Promise<void>;
|
|
||||||
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
|
|
||||||
getSessionInfo: (
|
|
||||||
guacamoleConnectionId: string,
|
|
||||||
) => GuacamoleSessionInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let bridge: GuacamoleBridge | null = null;
|
|
||||||
|
|
||||||
export function registerGuacamoleBridge(next: GuacamoleBridge): void {
|
|
||||||
bridge = next;
|
|
||||||
databaseLogger.info("Registered Remote Desktop plugin bridge", {
|
|
||||||
operation: "guacamole_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterGuacamoleBridge(): void {
|
|
||||||
if (!bridge) return;
|
|
||||||
bridge = null;
|
|
||||||
databaseLogger.info("Unregistered Remote Desktop plugin bridge", {
|
|
||||||
operation: "guacamole_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits
|
|
||||||
* the guacd URL setting (see user-settings-routes.ts). A no-op while the
|
|
||||||
* plugin is disabled/uninstalled -- restarting a server that does not exist
|
|
||||||
* would otherwise silently resurrect it outside the plugin lifecycle.
|
|
||||||
*/
|
|
||||||
export async function restartGuacamoleService(): Promise<void> {
|
|
||||||
if (!bridge) return;
|
|
||||||
await bridge.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mints a read-only/writable join token for an existing live Guacamole
|
|
||||||
* session, for collab/session-sharing to hand to an invited viewer. Throws
|
|
||||||
* when the plugin is disabled/uninstalled -- unlike the read-only lookups
|
|
||||||
* below, a caller asking to mint a join token needs to know it failed rather
|
|
||||||
* than silently getting an unusable value.
|
|
||||||
*/
|
|
||||||
export function createGuacamoleJoinToken(
|
|
||||||
guacamoleConnectionId: string,
|
|
||||||
readOnly: boolean,
|
|
||||||
): string {
|
|
||||||
if (!bridge) {
|
|
||||||
throw new Error("Remote Desktop plugin is not available");
|
|
||||||
}
|
|
||||||
return bridge.createJoinToken(guacamoleConnectionId, readOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Looks up a live Guacamole session by guacd's own connection id. Returns
|
|
||||||
* null both when no such session exists and when the plugin is
|
|
||||||
* disabled/uninstalled -- to a caller checking "is this session live", the
|
|
||||||
* two cases mean the same thing.
|
|
||||||
*/
|
|
||||||
export function getGuacamoleSessionInfo(
|
|
||||||
guacamoleConnectionId: string,
|
|
||||||
): GuacamoleSessionInfo | null {
|
|
||||||
return bridge?.getSessionInfo(guacamoleConnectionId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function guacamoleDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!bridge) {
|
|
||||||
res.status(404).json({ error: "Remote Desktop is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bridge.router(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
import type { Request, RequestHandler, Response, Router } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCommandHistoryRepository,
|
createCurrentCommandHistoryRepository,
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
@@ -23,7 +24,6 @@ type HostFolderRoutesDeps = {
|
|||||||
requireDeletePermission: RequestHandler;
|
requireDeletePermission: RequestHandler;
|
||||||
requireCredentialEditPermission: RequestHandler;
|
requireCredentialEditPermission: RequestHandler;
|
||||||
requireDataAccess: RequestHandler;
|
requireDataAccess: RequestHandler;
|
||||||
statsServerUrl: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function registerHostFolderRoutes(
|
export function registerHostFolderRoutes(
|
||||||
@@ -35,7 +35,6 @@ export function registerHostFolderRoutes(
|
|||||||
requireDeletePermission,
|
requireDeletePermission,
|
||||||
requireCredentialEditPermission,
|
requireCredentialEditPermission,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
statsServerUrl,
|
|
||||||
}: HostFolderRoutesDeps,
|
}: HostFolderRoutesDeps,
|
||||||
): void {
|
): void {
|
||||||
/**
|
/**
|
||||||
@@ -444,30 +443,11 @@ export function registerHostFolderRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
|
||||||
for (const host of hostsToDelete) {
|
for (const host of hostsToDelete) {
|
||||||
try {
|
pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId });
|
||||||
await axios.post(
|
|
||||||
`${statsServerUrl}/host-deleted`,
|
|
||||||
{ hostId: host.id },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: req.headers.authorization || "",
|
|
||||||
Cookie: req.headers.cookie || "",
|
|
||||||
},
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
|
||||||
operation: "folder_hosts_delete",
|
|
||||||
hostId: host.id,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.warn("Failed to notify stats server of folder deletion", {
|
sshLogger.warn("Failed to publish host deletion events", {
|
||||||
operation: "folder_hosts_delete",
|
operation: "folder_hosts_delete",
|
||||||
folderName,
|
folderName,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js
|
|||||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import axios from "axios";
|
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../../plugins/events.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 { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
@@ -82,32 +82,29 @@ const router = express.Router();
|
|||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
const STATS_SERVER_URL = "http://localhost:30005";
|
/**
|
||||||
|
* Tells whoever is polling this host that its details changed.
|
||||||
|
*
|
||||||
|
* An event rather than the HTTP POST to localhost:30005 this used to be: host
|
||||||
|
* metrics is a plugin now and has no port of its own. Fire and forget, so a
|
||||||
|
* subscriber that throws cannot fail the host update that caused it.
|
||||||
|
*/
|
||||||
function notifyStatsHostUpdated(
|
function notifyStatsHostUpdated(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
userId: string,
|
||||||
operation: string,
|
operation: string,
|
||||||
): void {
|
): void {
|
||||||
axios
|
try {
|
||||||
.post(
|
// The user travels with the event: a subscriber re-reading the host needs
|
||||||
`${STATS_SERVER_URL}/host-updated`,
|
// a data key, and the bus carries no session of its own.
|
||||||
{ hostId },
|
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||||
{
|
} catch (err) {
|
||||||
headers: {
|
sshLogger.warn("Failed to publish host update event", {
|
||||||
Authorization: headers.authorization || "",
|
operation,
|
||||||
Cookie: headers.cookie || "",
|
hostId,
|
||||||
},
|
error: err instanceof Error ? err.message : String(err),
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
sshLogger.warn("Failed to notify stats server of host update", {
|
|
||||||
operation,
|
|
||||||
hostId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -566,11 +563,7 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
res.json(stripSensitiveFields(resolvedHost));
|
res.json(stripSensitiveFields(resolvedHost));
|
||||||
notifyStatsHostUpdated(
|
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||||
createdHost.id as number,
|
|
||||||
req.headers,
|
|
||||||
"host_create",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to save SSH host to database", err, {
|
sshLogger.error("Failed to save SSH host to database", err, {
|
||||||
operation: "host_create",
|
operation: "host_create",
|
||||||
@@ -1483,7 +1476,7 @@ router.put(
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json(stripSensitiveFields(resolvedHost));
|
res.json(stripSensitiveFields(resolvedHost));
|
||||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to update SSH host in database", err, {
|
sshLogger.error("Failed to update SSH host in database", err, {
|
||||||
operation: "host_update",
|
operation: "host_update",
|
||||||
@@ -2551,20 +2544,12 @@ router.delete(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
pluginEvents.emit(TOPICS.hostDeleted, {
|
||||||
await axios.post(
|
hostId: numericHostId,
|
||||||
`${STATS_SERVER_URL}/host-deleted`,
|
userId,
|
||||||
{ hostId: numericHostId },
|
});
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: req.headers.authorization || "",
|
|
||||||
Cookie: req.headers.cookie || "",
|
|
||||||
},
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
sshLogger.warn("Failed to publish host deletion event", {
|
||||||
operation: "host_delete",
|
operation: "host_delete",
|
||||||
hostId: numericHostId,
|
hostId: numericHostId,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
@@ -2871,7 +2856,6 @@ registerHostFolderRoutes(router, {
|
|||||||
requireCredentialEditPermission:
|
requireCredentialEditPermission:
|
||||||
permissionManager.requirePermission("credentials.edit"),
|
permissionManager.requirePermission("credentials.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
statsServerUrl: STATS_SERVER_URL,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
registerHostBulkRoutes(
|
registerHostBulkRoutes(
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
// Mounted at /network-topology. Network Topology ships as a first-party
|
|
||||||
// in-process plugin (plugins/network-topology) rather than a route file
|
|
||||||
// imported directly here, so this module exists to keep /network-topology/*
|
|
||||||
// working at its original path without handing the plugin a reference to the
|
|
||||||
// shared app instance. The plugin runtime registers its router via
|
|
||||||
// registerNetworkTopologyRouter when it activates, and clears it on
|
|
||||||
// deactivate, so a disabled plugin falls back to the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Network Topology plugin's own), and
|
|
||||||
// every route in that router already applies authenticateJWT itself, the same
|
|
||||||
// as every other router mounted alongside it here (/users, /host, /snippets,
|
|
||||||
// ...). /plugin-api gates at the mount point because it forwards to arbitrary
|
|
||||||
// third-party plugin code that cannot be assumed to gate itself; that
|
|
||||||
// reasoning does not apply to a single first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerNetworkTopologyRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Network Topology plugin router", {
|
|
||||||
operation: "network_topology_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterNetworkTopologyRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Network Topology plugin router", {
|
|
||||||
operation: "network_topology_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function networkTopologyDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Network Topology is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,58 +1,34 @@
|
|||||||
// Mounted at /plugin-api. Every installed plugin that exposes backend routes
|
// Mounted at /plugin-api. Every plugin that serves HTTP registers a router
|
||||||
// registers a sub-router here under its own plugin id, and this dispatcher
|
// through ctx.http.router(), and this dispatcher forwards requests to it by
|
||||||
// forwards matching requests to it. The plugin runtime populates the map via
|
// plugin id. The routers themselves live in src/backend/plugins/http.ts, which
|
||||||
// registerPluginRouter when a plugin activates, and clears it on deactivate,
|
// also owns the middleware stack in front of them: auth, the actor, the
|
||||||
// so an inactive plugin falls back to the 404 below.
|
// enabled check, body limits and the error wrapper.
|
||||||
//
|
//
|
||||||
// WebSocket note: plugin WS traffic does NOT get its own port from the
|
// The dispatcher stays deliberately thin. It resolves a plugin id to a router
|
||||||
// 30001-30006 range. It rides the existing WS servers (e.g. the terminal or
|
// and nothing else, so there is no place here for a check keyed on a
|
||||||
// tunnel WS servers) using a named-channel envelope, the same way other
|
// particular plugin.
|
||||||
// features multiplex over a shared connection. This is a settled decision,
|
//
|
||||||
// not a placeholder, so it should not need revisiting when plugin WS support
|
// WebSocket note: plugin sockets do NOT ride this router, and they do not get
|
||||||
// is implemented.
|
// their own port either. They are served at /plugin-ws/<id>/<path> from the
|
||||||
|
// main server's upgrade event by src/backend/plugins/ws.ts. (An earlier
|
||||||
|
// comment here described a named-channel envelope multiplexed over the
|
||||||
|
// existing WS servers; A4 replaced that with the /plugin-ws prefix.)
|
||||||
|
|
||||||
import express, { type Request, type Response, type Router } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { getPluginRouter } from "../../plugins/http.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const activePluginRouters = new Map<string, Router>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the plugin runtime when a plugin activates. Replacing an existing
|
|
||||||
* entry is normal: a crash-restart re-registers under the same id.
|
|
||||||
*/
|
|
||||||
export function registerPluginRouter(
|
|
||||||
pluginId: string,
|
|
||||||
pluginRouter: Router,
|
|
||||||
): void {
|
|
||||||
activePluginRouters.set(pluginId, pluginRouter);
|
|
||||||
databaseLogger.info("Registered plugin API router", {
|
|
||||||
operation: "plugin_api_register",
|
|
||||||
pluginId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterPluginRouter(pluginId: string): void {
|
|
||||||
if (!activePluginRouters.delete(pluginId)) return;
|
|
||||||
databaseLogger.info("Unregistered plugin API router", {
|
|
||||||
operation: "plugin_api_register",
|
|
||||||
pluginId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRegisteredPluginIds(): string[] {
|
|
||||||
return [...activePluginRouters.keys()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugin-api/{pluginId}/{path}:
|
* /plugin-api/{pluginId}/{path}:
|
||||||
* get:
|
* get:
|
||||||
* summary: Dispatch a request to an installed plugin's backend router
|
* summary: Dispatch a request to an installed plugin's backend router
|
||||||
* description: >
|
* description: >
|
||||||
* Forwards the request to the sub-router registered by the plugin with
|
* Forwards the request to the router the plugin with the given id
|
||||||
* the given id. Returns 404 if the plugin is not installed or not
|
* registered through ctx.http.router(). Returns 404 if the plugin is not
|
||||||
|
* installed or serves no routes, and 503 if it is installed but not
|
||||||
* currently running. All HTTP methods are dispatched the same way.
|
* currently running. All HTTP methods are dispatched the same way.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
@@ -62,15 +38,24 @@ export function getRegisteredPluginIds(): string[] {
|
|||||||
* required: true
|
* required: true
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
|
* - in: path
|
||||||
|
* name: path
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Response from the plugin's router.
|
* description: Response from the plugin's router.
|
||||||
|
* 401:
|
||||||
|
* description: Authentication required.
|
||||||
* 404:
|
* 404:
|
||||||
* description: Plugin not installed or not running.
|
* description: Plugin not installed or serves no routes.
|
||||||
|
* 503:
|
||||||
|
* description: Plugin is installed but not running.
|
||||||
*/
|
*/
|
||||||
router.use("/:pluginId", (req: Request, res: Response, next) => {
|
router.use("/:pluginId", (req: Request, res: Response, next) => {
|
||||||
const pluginId = String(req.params.pluginId);
|
const pluginId = String(req.params.pluginId);
|
||||||
const pluginRouter = activePluginRouters.get(pluginId);
|
const pluginRouter = getPluginRouter(pluginId);
|
||||||
|
|
||||||
if (!pluginRouter) {
|
if (!pluginRouter) {
|
||||||
databaseLogger.warn("Plugin API request for unregistered plugin", {
|
databaseLogger.warn("Plugin API request for unregistered plugin", {
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
// Mounted at /proxmox. The Proxmox discovery/import/sync feature ships as a
|
|
||||||
// first-party in-process plugin (plugins/proxmox) rather than a route file
|
|
||||||
// imported directly here, so this module exists to keep /proxmox/* working
|
|
||||||
// at its original path without handing the plugin a reference to the shared
|
|
||||||
// app instance. The plugin runtime registers its router via
|
|
||||||
// registerProxmoxRouter when it activates, and clears it on deactivate, so a
|
|
||||||
// disabled plugin falls back to the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Proxmox plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT/requireDataAccess
|
|
||||||
// itself, the same as every other router mounted alongside it here (/users,
|
|
||||||
// /host, /snippets, ...). /plugin-api gates at the mount point because it
|
|
||||||
// forwards to arbitrary third-party plugin code that cannot be assumed to
|
|
||||||
// gate itself; that reasoning does not apply to a single first-party router
|
|
||||||
// that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerProxmoxRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Proxmox plugin router", {
|
|
||||||
operation: "proxmox_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterProxmoxRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Proxmox plugin router", {
|
|
||||||
operation: "proxmox_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function proxmoxDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Proxmox discovery is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// Mounted at /tailscale. Tailscale ships as a first-party in-process plugin
|
|
||||||
// (plugins/tailscale) rather than a route file imported directly here, so
|
|
||||||
// this module exists to keep /tailscale/* working at its original path
|
|
||||||
// without handing the plugin a reference to the shared app instance. The
|
|
||||||
// plugin runtime registers its router via registerTailscaleRouter when it
|
|
||||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
|
||||||
// the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Tailscale plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT itself, the same as
|
|
||||||
// every other router mounted alongside it here. /plugin-api gates at the
|
|
||||||
// mount point because it forwards to arbitrary third-party plugin code that
|
|
||||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
|
||||||
// first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerTailscaleRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Tailscale plugin router", {
|
|
||||||
operation: "tailscale_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterTailscaleRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Tailscale plugin router", {
|
|
||||||
operation: "tailscale_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function tailscaleDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Tailscale is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import type { RequestHandler, Router } from "express";
|
|||||||
// importing the plugin's TypeScript directly, since plugins/ is compiled
|
// importing the plugin's TypeScript directly, since plugins/ is compiled
|
||||||
// separately from src/backend/ and core cannot statically import across that
|
// separately from src/backend/ and core cannot statically import across that
|
||||||
// boundary. See guacamole-dispatch.ts and that plugin's README.
|
// boundary. See guacamole-dispatch.ts and that plugin's README.
|
||||||
import { restartGuacamoleService as restartGuacServer } from "./guacamole-dispatch.js";
|
import { restartGuacamoleService as restartGuacServer } from "../../hosts/guacamole-sessions.js";
|
||||||
import {
|
import {
|
||||||
authLogger,
|
authLogger,
|
||||||
getGlobalLogLevel,
|
getGlobalLogLevel,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
// Mounted at /workspaces. Workspaces ships as a first-party in-process
|
|
||||||
// plugin (plugins/workspaces) rather than a route file imported directly
|
|
||||||
// here, so this module exists to keep /workspaces/* working at its original
|
|
||||||
// path without handing the plugin a reference to the shared app instance.
|
|
||||||
// The plugin runtime registers its router via registerWorkspacesRouter when
|
|
||||||
// it activates, and clears it on deactivate, so a disabled plugin falls back
|
|
||||||
// to the 404 below.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Workspaces plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT itself, the same as
|
|
||||||
// every other router mounted alongside it here (/users, /host, /snippets,
|
|
||||||
// ...). /plugin-api gates at the mount point because it forwards to arbitrary
|
|
||||||
// third-party plugin code that cannot be assumed to gate itself; that
|
|
||||||
// reasoning does not apply to a single first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerWorkspacesRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
databaseLogger.info("Registered Workspaces plugin router", {
|
|
||||||
operation: "workspaces_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterWorkspacesRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
databaseLogger.info("Unregistered Workspaces plugin router", {
|
|
||||||
operation: "workspaces_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function workspaceDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Workspaces is not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
// README -- this is an accepted, temporary coupling until collab becomes a
|
// README -- this is an accepted, temporary coupling until collab becomes a
|
||||||
// plugin of its own.
|
// plugin of its own.
|
||||||
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
|
import { createGuacamoleJoinToken } from "../guacamole-sessions.js";
|
||||||
import { collabRoomHub } from "./room-hub.js";
|
import { collabRoomHub } from "./room-hub.js";
|
||||||
import { getStageController, setStageController } from "./stage-control.js";
|
import { getStageController, setStageController } from "./stage-control.js";
|
||||||
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
||||||
@@ -1091,7 +1091,7 @@ router.get("/guest/:token", async (req: Request, res: Response) => {
|
|||||||
shareId: share.id,
|
shareId: share.id,
|
||||||
...(protocol === "ssh"
|
...(protocol === "ssh"
|
||||||
? {
|
? {
|
||||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(token)}`,
|
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(token)}`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
connectParams: {
|
connectParams: {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* What core needs from the Remote Desktop plugin, resolved through the plugin
|
||||||
|
* registry rather than an import.
|
||||||
|
*
|
||||||
|
* collab, session-sharing and the admin guacd setting all need three things
|
||||||
|
* from a running Guacamole server: mint a join token, look up a live session,
|
||||||
|
* restart after a settings change. None of them can import the plugin -- core
|
||||||
|
* cannot statically import something outside its rootDir -- and the plugin
|
||||||
|
* cannot hand core a module reference without becoming core again.
|
||||||
|
*
|
||||||
|
* So the plugin publishes "remote-desktop.sessions" through ctx.registry on
|
||||||
|
* activate, the registry revokes it on deactivate, and the helpers below
|
||||||
|
* degrade the way each caller needs when nothing is published:
|
||||||
|
*
|
||||||
|
* - a lookup returns null, because "no such live session" and "the plugin is
|
||||||
|
* off" mean the same thing to a caller asking whether a session is live;
|
||||||
|
* - a restart is a no-op, because resurrecting a server the plugin does not
|
||||||
|
* own would be worse than doing nothing;
|
||||||
|
* - minting a token throws, because a caller about to hand a viewer a join
|
||||||
|
* link needs to know it failed rather than get an unusable value.
|
||||||
|
*
|
||||||
|
* This replaces the guacamole-dispatch bridge that used to live in
|
||||||
|
* database/routes/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { consume } from "../plugins/registry.js";
|
||||||
|
|
||||||
|
export const GUACAMOLE_SESSIONS_KEY = "remote-desktop.sessions";
|
||||||
|
|
||||||
|
export interface GuacamoleSessionInfo {
|
||||||
|
guacamoleConnectionId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
openedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleSessionProvider {
|
||||||
|
restart: () => Promise<void>;
|
||||||
|
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
|
||||||
|
getSessionInfo: (
|
||||||
|
guacamoleConnectionId: string,
|
||||||
|
) => GuacamoleSessionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(): GuacamoleSessionProvider | undefined {
|
||||||
|
return consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits the
|
||||||
|
* guacd URL setting. A no-op while the plugin is disabled.
|
||||||
|
*/
|
||||||
|
export async function restartGuacamoleService(): Promise<void> {
|
||||||
|
await provider()?.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mints a read-only or writable join token for a live Guacamole session, for
|
||||||
|
* collab and session-sharing to hand an invited viewer. Throws when the plugin
|
||||||
|
* is not running.
|
||||||
|
*/
|
||||||
|
export function createGuacamoleJoinToken(
|
||||||
|
guacamoleConnectionId: string,
|
||||||
|
readOnly: boolean,
|
||||||
|
): string {
|
||||||
|
const active = provider();
|
||||||
|
if (!active) {
|
||||||
|
throw new Error("Remote Desktop plugin is not available");
|
||||||
|
}
|
||||||
|
return active.createJoinToken(guacamoleConnectionId, readOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks up a live Guacamole session by guacd's own connection id. */
|
||||||
|
export function getGuacamoleSessionInfo(
|
||||||
|
guacamoleConnectionId: string,
|
||||||
|
): GuacamoleSessionInfo | null {
|
||||||
|
return provider()?.getSessionInfo(guacamoleConnectionId) ?? null;
|
||||||
|
}
|
||||||
@@ -544,10 +544,13 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
|||||||
|
|
||||||
session.localPort = actualPort;
|
session.localPort = actualPort;
|
||||||
|
|
||||||
const baseUrl = session.remoteRedirectUri
|
// No port rewrite any more: the terminal socket used to live on 30002,
|
||||||
.replace(/\/host\/opkssh-callback$/, "")
|
// separate from the HTTP API, so a redirect URI derived from it pointed at
|
||||||
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
|
// the wrong server in direct dev mode. The terminal rides 30001 now.
|
||||||
.replace(/:30002\b/, ":30001");
|
const baseUrl = session.remoteRedirectUri.replace(
|
||||||
|
/\/host\/opkssh-callback$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
||||||
|
|
||||||
session.status = "waiting_for_auth";
|
session.status = "waiting_for_auth";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { sessionManager } from "../terminal/session-manager.js";
|
|||||||
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
// README -- this is an accepted, temporary coupling until collab/
|
// README -- this is an accepted, temporary coupling until collab/
|
||||||
// session-sharing themselves become plugins.
|
// session-sharing themselves become plugins.
|
||||||
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../../database/routes/guacamole-dispatch.js";
|
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../guacamole-sessions.js";
|
||||||
import {
|
import {
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
|
||||||
// README -- this is an accepted, temporary coupling until session-sharing
|
// README -- this is an accepted, temporary coupling until session-sharing
|
||||||
// becomes a plugin of its own.
|
// becomes a plugin of its own.
|
||||||
import { createGuacamoleJoinToken } from "../../database/routes/guacamole-dispatch.js";
|
import { createGuacamoleJoinToken } from "../guacamole-sessions.js";
|
||||||
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -405,10 +405,13 @@ router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
|||||||
} = {
|
} = {
|
||||||
protocol,
|
protocol,
|
||||||
permissionLevel: share.permissionLevel as PermissionLevel,
|
permissionLevel: share.permissionLevel as PermissionLevel,
|
||||||
|
// Advisory only: the client builds its own socket URL per deployment
|
||||||
|
// (see pluginWsUrl). Kept accurate rather than left pointing at the
|
||||||
|
// ports these two moved off in A4.
|
||||||
wsPath:
|
wsPath:
|
||||||
protocol === "ssh"
|
protocol === "ssh"
|
||||||
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
|
? `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(linkToken)}`
|
||||||
: "/guacamole/websocket/",
|
: "/plugin-ws/remote-desktop/display",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (protocol !== "ssh") {
|
if (protocol !== "ssh") {
|
||||||
|
|||||||
@@ -139,68 +139,49 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000;
|
|||||||
|
|
||||||
const userConnections = new Map<string, Set<WebSocket>>();
|
const userConnections = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
export const TERMINAL_WS_PORT = 30002;
|
|
||||||
|
|
||||||
// Re-exported so the ssh-terminal plugin can publish it on the service
|
// Re-exported so the ssh-terminal plugin can publish it on the service
|
||||||
// registry. session-manager itself stays in core: six modules outside the
|
// registry. session-manager itself stays in core: six modules outside the
|
||||||
// terminal import it directly.
|
// terminal import it directly.
|
||||||
export { sessionManager };
|
export { sessionManager };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The server is created but not listening until startTerminalServer() runs.
|
* Serves /plugin-ws/ssh-terminal/terminal through the main backend server.
|
||||||
* The ssh-terminal plugin owns that lifecycle, so disabling the plugin has to
|
|
||||||
* actually free the port rather than just stop routing to it.
|
|
||||||
*
|
*
|
||||||
* `noServer` avoids binding at import time; listen() below is what opens the
|
* `noServer` because core owns the listening socket now: the ssh-terminal
|
||||||
* port. Handlers are attached at module scope as before, so none of the
|
* plugin registers handleTerminalUpgrade below through ctx.ws and there is no
|
||||||
|
* terminal port any more. Handlers stay at module scope, so none of the
|
||||||
* connection logic below had to change.
|
* connection logic below had to change.
|
||||||
*/
|
*/
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
let httpServer: import("http").Server | null = null;
|
|
||||||
|
|
||||||
wss.on("error", (error) => {
|
wss.on("error", (error) => {
|
||||||
sshLogger.error("WebSocket server error", error, {
|
sshLogger.error("WebSocket server error", error, {
|
||||||
operation: "wss_error",
|
operation: "wss_error",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function startTerminalServer(
|
/**
|
||||||
port: number = TERMINAL_WS_PORT,
|
* Hands one upgrade to the terminal's own WebSocketServer.
|
||||||
): Promise<void> {
|
*
|
||||||
if (httpServer) return;
|
* The connection handler below authenticates each socket itself, because a
|
||||||
|
* share-link guest arrives with a share token rather than a session. So this
|
||||||
const { createServer } = await import("http");
|
* is registered as a public ctx.ws route and the auth stays where it already
|
||||||
const server = createServer();
|
* is, rather than being split across two places.
|
||||||
|
*/
|
||||||
server.on("upgrade", (req, socket, head) => {
|
export function handleTerminalUpgrade(
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
req: import("http").IncomingMessage,
|
||||||
wss.emit("connection", ws, req);
|
socket: import("stream").Duplex,
|
||||||
});
|
head: Buffer,
|
||||||
});
|
): void {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
await new Promise<void>((resolve, reject) => {
|
wss.emit("connection", ws, req);
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(port, "127.0.0.1", () => {
|
|
||||||
server.off("error", reject);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
httpServer = server;
|
|
||||||
sshLogger.info(`Terminal WebSocket server listening on ${port}`, {
|
|
||||||
operation: "terminal_ws_start",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopTerminalServer(): Promise<void> {
|
export async function stopTerminalServer(): Promise<void> {
|
||||||
if (!httpServer) return;
|
// No port to free any more: the route is unmounted by the runtime. The live
|
||||||
|
// sockets still have to be closed, because a terminal session would
|
||||||
const server = httpServer;
|
// otherwise outlive the plugin that owns it.
|
||||||
httpServer = null;
|
|
||||||
|
|
||||||
// Close live sockets first: server.close() waits for connections to drain,
|
|
||||||
// and a terminal session would otherwise hold the port open indefinitely.
|
|
||||||
for (const ws of wss.clients) {
|
for (const ws of wss.clients) {
|
||||||
try {
|
try {
|
||||||
ws.close(1001, "Terminal plugin disabled");
|
ws.close(1001, "Terminal plugin disabled");
|
||||||
@@ -209,8 +190,7 @@ export async function stopTerminalServer(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
sshLogger.info("Terminal WebSocket sessions closed", {
|
||||||
sshLogger.info("Terminal WebSocket server stopped", {
|
|
||||||
operation: "terminal_ws_stop",
|
operation: "terminal_ws_stop",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "./c2s-relay.js";
|
} from "./c2s-relay.js";
|
||||||
|
|
||||||
import { registerTunnelRoutes } from "./routes.js";
|
import { registerTunnelRoutes } from "./routes.js";
|
||||||
import webEndpointDispatch from "./web-endpoint-dispatch.js";
|
|
||||||
import { initializeAutoStartTunnels } from "./manager.js";
|
import { initializeAutoStartTunnels } from "./manager.js";
|
||||||
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
|
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
|
||||||
|
|
||||||
@@ -39,9 +38,6 @@ app.use((_req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerTunnelRoutes(app);
|
registerTunnelRoutes(app);
|
||||||
// Always mounted; the Web Endpoint plugin registers its router behind this on
|
|
||||||
// activate and clears it on deactivate. See web-endpoint-dispatch.ts.
|
|
||||||
app.use("/ssh/tunnel/web-endpoint", webEndpointDispatch);
|
|
||||||
|
|
||||||
const PORT = 30003;
|
const PORT = 30003;
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
// Mounted at /ssh/tunnel/web-endpoint on the tunnel service's app. Web
|
|
||||||
// Endpoint ships as a first-party in-process plugin (plugins/web-endpoint)
|
|
||||||
// rather than a route file imported directly by index.ts, so this module
|
|
||||||
// exists to keep /ssh/tunnel/web-endpoint/* working at its original path
|
|
||||||
// without handing the plugin a reference to the shared app instance. The
|
|
||||||
// plugin runtime registers its router via registerWebEndpointRouter when it
|
|
||||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
|
||||||
// the 404 below.
|
|
||||||
//
|
|
||||||
// This is the only dispatcher that lives outside database.ts: the route is
|
|
||||||
// served by the tunnel service on port 30003, and nginx already proxies the
|
|
||||||
// whole /ssh/tunnel/ prefix there. Both that service and the plugin runtime
|
|
||||||
// are loaded into the same backend process (starter.ts imports
|
|
||||||
// hosts/tunnel/index.js), so the module-level slot below is shared state
|
|
||||||
// between them, exactly as it is for the dispatchers in database.ts.
|
|
||||||
//
|
|
||||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
|
||||||
// to exactly one first-party router (the Web Endpoint plugin's own), and every
|
|
||||||
// route in that router already applies authenticateJWT itself, the same as
|
|
||||||
// every other route registered alongside it here. /plugin-api gates at the
|
|
||||||
// mount point because it forwards to arbitrary third-party plugin code that
|
|
||||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
|
||||||
// first-party router that already does.
|
|
||||||
|
|
||||||
import type { Request, Response, NextFunction, Router } from "express";
|
|
||||||
import { tunnelLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
let activeRouter: Router | null = null;
|
|
||||||
|
|
||||||
export function registerWebEndpointRouter(router: Router): void {
|
|
||||||
activeRouter = router;
|
|
||||||
tunnelLogger.info("Registered Web Endpoint plugin router", {
|
|
||||||
operation: "web_endpoint_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterWebEndpointRouter(): void {
|
|
||||||
if (!activeRouter) return;
|
|
||||||
activeRouter = null;
|
|
||||||
tunnelLogger.info("Unregistered Web Endpoint plugin router", {
|
|
||||||
operation: "web_endpoint_dispatch_register",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function webEndpointDispatch(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
): void {
|
|
||||||
if (!activeRouter) {
|
|
||||||
res.status(404).json({ error: "Web endpoints are not available" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeRouter(req, res, next);
|
|
||||||
}
|
|
||||||
@@ -29,8 +29,18 @@ import type { SecretRegistration } from "./secret-registry.js";
|
|||||||
import { assertCapability } from "./permissions.js";
|
import { assertCapability } from "./permissions.js";
|
||||||
import { getActor, runAsActor } from "./actor.js";
|
import { getActor, runAsActor } from "./actor.js";
|
||||||
import { DisposableBag } from "./disposables.js";
|
import { DisposableBag } from "./disposables.js";
|
||||||
|
import {
|
||||||
|
createPluginRouter,
|
||||||
|
createRbacMiddleware,
|
||||||
|
unregisterPluginHttp,
|
||||||
|
} from "./http.js";
|
||||||
|
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
|
||||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
import type { PluginContext, PluginModule } from "@termix/plugin-sdk/backend";
|
import {
|
||||||
|
PluginCapabilityError,
|
||||||
|
type PluginContext,
|
||||||
|
type PluginModule,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||||
import * as syncRegistry from "./sync-registry.js";
|
import * as syncRegistry from "./sync-registry.js";
|
||||||
|
|
||||||
@@ -410,6 +420,63 @@ export function createPluginContext(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
http: {
|
||||||
|
router: (options) => {
|
||||||
|
// Declared here, granted per request: the grant lives in the database
|
||||||
|
// and router() has to be synchronous so a plugin can register routes
|
||||||
|
// inline in activate. http.ts re-checks on every request.
|
||||||
|
if (!declared.includes("network:serve")) {
|
||||||
|
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = createPluginRouter({
|
||||||
|
manifest,
|
||||||
|
options,
|
||||||
|
reportError: (error) => void reportRuntimeError(pluginId, error),
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.bag.add(
|
||||||
|
() => unregisterPluginHttp(pluginId),
|
||||||
|
`HTTP router for ${pluginId}`,
|
||||||
|
);
|
||||||
|
return router as never;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ws: {
|
||||||
|
route: (path, wsHandler, options) => {
|
||||||
|
if (!declared.includes("network:serve")) {
|
||||||
|
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||||
|
}
|
||||||
|
const dispose = registerPluginWsRoute(
|
||||||
|
pluginId,
|
||||||
|
path,
|
||||||
|
wsHandler,
|
||||||
|
declared,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
handle.bag.add(dispose, `WebSocket route "${path}"`);
|
||||||
|
},
|
||||||
|
|
||||||
|
upgrade: (path, upgradeHandler, options) => {
|
||||||
|
if (!declared.includes("network:serve")) {
|
||||||
|
throw new PluginCapabilityError(pluginId, "network:serve");
|
||||||
|
}
|
||||||
|
const dispose = registerPluginWsUpgrade(
|
||||||
|
pluginId,
|
||||||
|
path,
|
||||||
|
upgradeHandler as never,
|
||||||
|
declared,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
handle.bag.add(dispose, `WebSocket upgrade "${path}"`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rbac: {
|
||||||
|
require: (permission) => createRbacMiddleware(manifest, permission),
|
||||||
|
},
|
||||||
|
|
||||||
disposables: {
|
disposables: {
|
||||||
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
|
add: (dispose) => handle.bag.add(dispose, "plugin resource"),
|
||||||
},
|
},
|
||||||
@@ -439,6 +506,24 @@ export function createPluginContext(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds a route or socket error into the loader's error budget.
|
||||||
|
*
|
||||||
|
* Lazily imported: index.ts owns the loader and imports this module, so a
|
||||||
|
* static import here would close the cycle.
|
||||||
|
*/
|
||||||
|
async function reportRuntimeError(
|
||||||
|
pluginId: string,
|
||||||
|
error: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { getPluginRuntime } = await import("./index.js");
|
||||||
|
await getPluginRuntime().loader.reportError(pluginId, error);
|
||||||
|
} catch {
|
||||||
|
// The budget is a safety net, not a dependency of serving a request.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertKvKey(key: string): void {
|
function assertKvKey(key: string): void {
|
||||||
if (typeof key !== "string" || key.length === 0) {
|
if (typeof key !== "string" || key.length === 0) {
|
||||||
throw new Error("ctx.kv keys must be non-empty strings");
|
throw new Error("ctx.kv keys must be non-empty strings");
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export const TOPICS = {
|
|||||||
hostStatus: "host.status",
|
hostStatus: "host.status",
|
||||||
/** A health check result. */
|
/** A health check result. */
|
||||||
hostHealthCheck: "host.health_check",
|
hostHealthCheck: "host.health_check",
|
||||||
|
/** A host's connection details changed and pollers should re-read them. */
|
||||||
|
hostUpdated: "host.updated",
|
||||||
|
/** A host was deleted and any poller holding it should drop it. */
|
||||||
|
hostDeleted: "host.deleted",
|
||||||
|
/** Someone logged in over SSH to a host. */
|
||||||
|
hostLogin: "host.login",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventListener = (payload: unknown) => void;
|
export type EventListener = (payload: unknown) => void;
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* HTTP for plugins: one mount point, one set of middleware, no ports.
|
||||||
|
*
|
||||||
|
* A plugin calls ctx.http.router() and gets an Express Router that core mounts
|
||||||
|
* at /plugin-api/<id>/. Everything a route needs in front of it runs here
|
||||||
|
* rather than in the plugin:
|
||||||
|
*
|
||||||
|
* 1. auth, unless the path was declared public;
|
||||||
|
* 2. the actor, so ctx and core APIs know who is calling;
|
||||||
|
* 3. an enabled check, so a disabled plugin answers 503 rather than 404;
|
||||||
|
* 4. body limits;
|
||||||
|
* 5. an error wrapper that logs against the plugin and leaks nothing.
|
||||||
|
*
|
||||||
|
* The wrapper never touches the response body, so SSE, file downloads and
|
||||||
|
* multipart uploads stream through untouched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, {
|
||||||
|
type NextFunction,
|
||||||
|
type Request,
|
||||||
|
type Response,
|
||||||
|
type Router,
|
||||||
|
} from "express";
|
||||||
|
import type {
|
||||||
|
PluginMiddleware,
|
||||||
|
PluginRouterOptions,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
import { runAsActor } from "./actor.js";
|
||||||
|
import { hasCapability } from "./permissions.js";
|
||||||
|
|
||||||
|
/** Matches what core's own routes accept, so moving a route changes nothing. */
|
||||||
|
const DEFAULT_BODY_LIMIT = "2mb";
|
||||||
|
|
||||||
|
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
|
||||||
|
const routers = new Map<string, Router>();
|
||||||
|
|
||||||
|
export function getPluginRouter(pluginId: string): Router | undefined {
|
||||||
|
return routers.get(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredHttpPluginIds(): string[] {
|
||||||
|
return [...routers.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterPluginHttp(pluginId: string): void {
|
||||||
|
if (!routers.delete(pluginId)) return;
|
||||||
|
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
|
||||||
|
operation: "plugin_http_unmount",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the plugin is currently running.
|
||||||
|
*
|
||||||
|
* Injected rather than imported so this module does not depend on the loader,
|
||||||
|
* which imports ctx.ts, which would close a cycle.
|
||||||
|
*/
|
||||||
|
type IsEnabled = (pluginId: string) => boolean;
|
||||||
|
|
||||||
|
let isPluginEnabled: IsEnabled = () => true;
|
||||||
|
|
||||||
|
export function setPluginEnabledCheck(check: IsEnabled): void {
|
||||||
|
isPluginEnabled = check;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam, so one test's stub does not leak into the next. */
|
||||||
|
export function resetPluginHttp(): void {
|
||||||
|
routers.clear();
|
||||||
|
isPluginEnabled = () => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicPath(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
return withSlash.length > 1 && withSlash.endsWith("/")
|
||||||
|
? withSlash.slice(0, -1)
|
||||||
|
: withSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles a declared public path into a matcher.
|
||||||
|
*
|
||||||
|
* Express-style ":param" segments are supported, because the routes that have
|
||||||
|
* to be public are usually keyed by a token ("/webhook/:token"). A parameter
|
||||||
|
* matches exactly one segment and never a slash, so ":token" cannot swallow
|
||||||
|
* the rest of the path and turn one declared route into a prefix hole.
|
||||||
|
*/
|
||||||
|
function compilePublicPath(path: string): RegExp {
|
||||||
|
const escaped = path
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.startsWith(":")) return "[^/]+";
|
||||||
|
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
})
|
||||||
|
.join("/");
|
||||||
|
return new RegExp(`^${escaped}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the request path against the declared public paths.
|
||||||
|
*
|
||||||
|
* Full-path match only. A prefix match would turn one declared route into a
|
||||||
|
* hole covering everything below it, which is not what declaring a single
|
||||||
|
* public path should mean.
|
||||||
|
*/
|
||||||
|
function isPublicRequest(req: Request, publicPaths: RegExp[]): boolean {
|
||||||
|
if (publicPaths.length === 0) return false;
|
||||||
|
const path = normalizePublicPath(req.path || "/");
|
||||||
|
return publicPaths.some((pattern) => pattern.test(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePluginRouterArgs {
|
||||||
|
manifest: PluginManifest;
|
||||||
|
options?: PluginRouterOptions;
|
||||||
|
/** Records a runtime error against the plugin's error budget. */
|
||||||
|
reportError: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginRouter({
|
||||||
|
manifest,
|
||||||
|
options,
|
||||||
|
reportError,
|
||||||
|
}: CreatePluginRouterArgs): Router {
|
||||||
|
const pluginId = manifest.id;
|
||||||
|
const declaredPublic = (options?.public ?? []).map((path) =>
|
||||||
|
normalizePublicPath(path),
|
||||||
|
);
|
||||||
|
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
|
||||||
|
|
||||||
|
if (declaredPublic.length > 0) {
|
||||||
|
// Audited once at registration: the set of unauthenticated routes a plugin
|
||||||
|
// has is worth seeing even if nobody ever calls them.
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Plugin ${pluginId} serves ${declaredPublic.length} route(s) without authentication: ${declaredPublic.join(", ")}`,
|
||||||
|
{ operation: "plugin_http_public" },
|
||||||
|
);
|
||||||
|
void writePublicRouteAudit(manifest, declaredPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outer = express.Router();
|
||||||
|
const inner = express.Router();
|
||||||
|
|
||||||
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (isPluginEnabled(pluginId)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(503).json({ error: "Plugin is not running", pluginId });
|
||||||
|
});
|
||||||
|
|
||||||
|
// The grant lives in the database, so it cannot be checked in the
|
||||||
|
// synchronous router() call a plugin makes inside activate. Checked per
|
||||||
|
// request instead: a plugin whose network:serve was revoked keeps its mount
|
||||||
|
// point and refuses every request through it, which is the same outcome a
|
||||||
|
// revoke should have and takes effect without a restart.
|
||||||
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const granted = await hasCapability(
|
||||||
|
pluginId,
|
||||||
|
"network:serve",
|
||||||
|
manifest.capabilities,
|
||||||
|
);
|
||||||
|
if (!granted) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: "Plugin is not granted the network:serve capability",
|
||||||
|
pluginId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = AuthManager.getInstance().createAuthMiddleware();
|
||||||
|
|
||||||
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (isPublicRequest(req, publicPaths)) {
|
||||||
|
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
|
||||||
|
operation: "plugin_http_public_request",
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authenticate(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options?.rawBody) {
|
||||||
|
const limit = options?.bodyLimit ?? DEFAULT_BODY_LIMIT;
|
||||||
|
// Both parsers ignore a body they do not claim, so multipart still reaches
|
||||||
|
// multer untouched. database.ts skips its own global parser for
|
||||||
|
// /plugin-api so this limit is the one that actually applies.
|
||||||
|
outer.use(express.json({ limit }));
|
||||||
|
outer.use(express.urlencoded({ limit, extended: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// After auth, so the actor is the user core authenticated rather than
|
||||||
|
// whatever the request claimed.
|
||||||
|
outer.use((req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
const userId = (req as Request & { userId?: string }).userId;
|
||||||
|
if (!userId) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runAsActor(userId, "request", () => next());
|
||||||
|
});
|
||||||
|
|
||||||
|
outer.use(inner);
|
||||||
|
|
||||||
|
outer.use(
|
||||||
|
(
|
||||||
|
error: unknown,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction,
|
||||||
|
): void => {
|
||||||
|
void _next;
|
||||||
|
reportError(error);
|
||||||
|
pluginLogger.error(
|
||||||
|
`Plugin ${pluginId} route ${req.method} ${req.path} failed`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_http_error" },
|
||||||
|
);
|
||||||
|
if (res.headersSent) {
|
||||||
|
// Mid-stream: the only honest thing left is to end it.
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Plugin request failed" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
routers.set(pluginId, outer);
|
||||||
|
pluginLogger.info(`Mounted /plugin-api/${pluginId}`, {
|
||||||
|
operation: "plugin_http_mount",
|
||||||
|
});
|
||||||
|
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePublicRouteAudit(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
paths: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { logAudit } = await import("../utils/audit-logger.js");
|
||||||
|
await logAudit({
|
||||||
|
userId: "system",
|
||||||
|
username: `plugin:${manifest.id}`,
|
||||||
|
action: "plugin_http_public_routes",
|
||||||
|
resourceType: "plugin",
|
||||||
|
resourceId: manifest.id,
|
||||||
|
resourceName: manifest.name,
|
||||||
|
details: `unauthenticated routes: ${paths.join(", ")}`,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Auditing must never stop a plugin starting.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds ctx.rbac.require.
|
||||||
|
*
|
||||||
|
* A plugin may only gate on a permission it declares itself. Without that a
|
||||||
|
* route could require admin.users.manage and borrow someone else's authority,
|
||||||
|
* which is the escalation shape the manifest validator already blocks for
|
||||||
|
* role defaults.
|
||||||
|
*/
|
||||||
|
export function createRbacMiddleware(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
permission: string,
|
||||||
|
): PluginMiddleware {
|
||||||
|
const declared = new Set(
|
||||||
|
manifest.contributes?.permissionGroup?.permissions ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!declared.has(permission)) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissionGroup.permissions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
void (async () => {
|
||||||
|
const userId = (req as Request & { userId?: string }).userId;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({ error: "Authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../utils/permission-manager.js");
|
||||||
|
const allowed = await PermissionManager.getInstance().hasPermission(
|
||||||
|
userId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
res.status(403).json({ error: "Permission denied", permission });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}) as unknown as PluginMiddleware;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
import { unregisterPluginRouter } from "../database/routes/plugin-api-routes.js";
|
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
|
||||||
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
import { PluginLoader, type LoadedPlugin } from "./loader.js";
|
||||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||||
|
|
||||||
@@ -19,6 +19,12 @@ let loader: PluginLoader | null = null;
|
|||||||
export function getPluginRuntime(): { loader: PluginLoader } {
|
export function getPluginRuntime(): { loader: PluginLoader } {
|
||||||
if (!loader) {
|
if (!loader) {
|
||||||
loader = new PluginLoader();
|
loader = new PluginLoader();
|
||||||
|
// The 503-while-disabled answer comes from here rather than from the
|
||||||
|
// router being torn down, so a request that arrives mid-disable gets a
|
||||||
|
// truthful status instead of a 404.
|
||||||
|
setPluginEnabledCheck(
|
||||||
|
(pluginId) => loader?.get(pluginId)?.state === "active",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { loader };
|
return { loader };
|
||||||
}
|
}
|
||||||
@@ -336,7 +342,7 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
|
|||||||
const plugin = pluginLoader.get(pluginId);
|
const plugin = pluginLoader.get(pluginId);
|
||||||
|
|
||||||
await pluginLoader.deactivate(pluginId);
|
await pluginLoader.deactivate(pluginId);
|
||||||
unregisterPluginRouter(pluginId);
|
unregisterPluginHttp(pluginId);
|
||||||
|
|
||||||
const group = plugin?.manifest.contributes?.permissionGroup;
|
const group = plugin?.manifest.contributes?.permissionGroup;
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -348,7 +354,7 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
|
|||||||
|
|
||||||
export async function shutdownPlugins(): Promise<void> {
|
export async function shutdownPlugins(): Promise<void> {
|
||||||
if (!loader) return;
|
if (!loader) return;
|
||||||
for (const plugin of loader.list()) unregisterPluginRouter(plugin.id);
|
for (const plugin of loader.list()) unregisterPluginHttp(plugin.id);
|
||||||
await loader.shutdown();
|
await loader.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* WebSockets for plugins: one upgrade handler, no ports.
|
||||||
|
*
|
||||||
|
* A plugin registers /plugin-ws/<id>/<path> and core serves it from the main
|
||||||
|
* server's upgrade event. Auth is the same as every other socket in Termix
|
||||||
|
* (utils/ws-auth.ts), the actor is set for the handler, and every socket a
|
||||||
|
* plugin opened is closed when it deactivates.
|
||||||
|
*
|
||||||
|
* Two shapes, because two kinds of caller exist:
|
||||||
|
*
|
||||||
|
* - route(): core owns the WebSocketServer and hands the plugin a live
|
||||||
|
* socket. What a plugin writing its own protocol wants.
|
||||||
|
* - upgrade(): core hands over the raw upgrade after authenticating it, for
|
||||||
|
* a library that insists on owning its own server. guacamole-lite is the
|
||||||
|
* reason this exists: it constructs a WebSocketServer internally, so the
|
||||||
|
* only way to attach it is to give it the upgrade.
|
||||||
|
*
|
||||||
|
* Both run auth first, so "bring your own server" never means "bring your own
|
||||||
|
* auth".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
|
import { WebSocketServer, type WebSocket } from "ws";
|
||||||
|
import type {
|
||||||
|
PluginWebSocketHandler,
|
||||||
|
PluginWebSocketOptions,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||||
|
import { runAsActor } from "./actor.js";
|
||||||
|
|
||||||
|
export const PLUGIN_WS_PREFIX = "/plugin-ws/";
|
||||||
|
|
||||||
|
type RawUpgradeHandler = (
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Duplex,
|
||||||
|
head: Buffer,
|
||||||
|
userId: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
pluginId: string;
|
||||||
|
path: string;
|
||||||
|
options: PluginWebSocketOptions;
|
||||||
|
/** Declared capabilities, for the per-upgrade network:serve check. */
|
||||||
|
declared: readonly string[];
|
||||||
|
handler?: PluginWebSocketHandler;
|
||||||
|
rawHandler?: RawUpgradeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyed by "<pluginId>:<path>". */
|
||||||
|
const routes = new Map<string, Route>();
|
||||||
|
|
||||||
|
/** One server per plugin route, so disposal can close exactly its sockets. */
|
||||||
|
const servers = new Map<string, WebSocketServer>();
|
||||||
|
|
||||||
|
const attachedServers = new Set<HttpServer>();
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return withSlash.length > 1 && withSlash.endsWith("/")
|
||||||
|
? withSlash.slice(0, -1)
|
||||||
|
: withSlash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(pluginId: string, path: string): string {
|
||||||
|
return `${pluginId}:${normalizePath(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses /plugin-ws/<id>/<path> out of an upgrade URL.
|
||||||
|
*
|
||||||
|
* Returns null for anything else, so core's own sockets and a stray request
|
||||||
|
* both fall through to whatever else is listening rather than being claimed.
|
||||||
|
*/
|
||||||
|
export function parsePluginWsUrl(
|
||||||
|
url: string | undefined,
|
||||||
|
): { pluginId: string; path: string } | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const pathname = url.split("?")[0];
|
||||||
|
if (!pathname.startsWith(PLUGIN_WS_PREFIX)) return null;
|
||||||
|
|
||||||
|
const rest = pathname.slice(PLUGIN_WS_PREFIX.length);
|
||||||
|
const slash = rest.indexOf("/");
|
||||||
|
if (slash <= 0) return null;
|
||||||
|
|
||||||
|
const pluginId = rest.slice(0, slash);
|
||||||
|
const path = normalizePath(rest.slice(slash));
|
||||||
|
if (!pluginId || path === "/") return null;
|
||||||
|
return { pluginId, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the user behind an upgrade, or null.
|
||||||
|
*
|
||||||
|
* A half-authenticated session is not a user: a token still awaiting TOTP is
|
||||||
|
* refused here exactly as createAuthMiddleware refuses it for HTTP, so a
|
||||||
|
* socket cannot be the way around the second factor.
|
||||||
|
*/
|
||||||
|
async function verifyToken(request: IncomingMessage): Promise<string | null> {
|
||||||
|
const token = extractWebSocketToken(request);
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||||
|
const payload = await AuthManager.getInstance().verifyJWTToken(token);
|
||||||
|
if (!payload || payload.pendingTOTP) return null;
|
||||||
|
return payload.userId ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(socket: Duplex, status: number, message: string): void {
|
||||||
|
try {
|
||||||
|
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\n\r\n`);
|
||||||
|
} catch {
|
||||||
|
// The peer may already be gone.
|
||||||
|
}
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves a plugin upgrade, or returns false so the caller can pass it on.
|
||||||
|
*
|
||||||
|
* Exported for the test suite and for database.ts, which wires it into both
|
||||||
|
* the HTTP and the HTTPS server.
|
||||||
|
*/
|
||||||
|
export async function handlePluginUpgrade(
|
||||||
|
request: IncomingMessage,
|
||||||
|
socket: Duplex,
|
||||||
|
head: Buffer,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const parsed = parsePluginWsUrl(request.url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
|
||||||
|
const route = routes.get(key(parsed.pluginId, parsed.path));
|
||||||
|
if (!route) {
|
||||||
|
// Claimed by the prefix but nothing is listening: answering here is more
|
||||||
|
// honest than leaving the socket hanging for a plugin that is disabled.
|
||||||
|
reject(socket, 404, "Not Found");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same reasoning as the HTTP side: the grant is in the database, so it is
|
||||||
|
// checked per upgrade rather than at registration, and a revoke takes effect
|
||||||
|
// on the next connection without a restart.
|
||||||
|
const { hasCapability } = await import("./permissions.js");
|
||||||
|
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
|
||||||
|
reject(socket, 403, "Forbidden");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = "";
|
||||||
|
if (!route.options.public) {
|
||||||
|
const resolved = await verifyToken(request);
|
||||||
|
if (!resolved) {
|
||||||
|
reject(socket, 401, "Unauthorized");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
userId = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.rawHandler) {
|
||||||
|
const rawHandler = route.rawHandler;
|
||||||
|
runAsActor(userId || "anonymous", "request", () =>
|
||||||
|
rawHandler(request, socket, head, userId),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = servers.get(key(route.pluginId, route.path));
|
||||||
|
if (!server) {
|
||||||
|
reject(socket, 503, "Service Unavailable");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||||
|
const handler = route.handler;
|
||||||
|
if (!handler) {
|
||||||
|
ws.close(1011, "No handler");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runAsActor(userId || "anonymous", "request", () => {
|
||||||
|
void Promise.resolve(handler({ userId, request, socket: ws })).catch(
|
||||||
|
(error) => {
|
||||||
|
pluginLogger.error(
|
||||||
|
`Plugin ${route.pluginId} socket ${route.path} failed`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_ws_error" },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
ws.close(1011, "Plugin error");
|
||||||
|
} catch {
|
||||||
|
// Already closed.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wires the upgrade handler onto a server. Idempotent per server. */
|
||||||
|
export function attachPluginWebSockets(server: HttpServer): void {
|
||||||
|
if (attachedServers.has(server)) return;
|
||||||
|
attachedServers.add(server);
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket, head) => {
|
||||||
|
void handlePluginUpgrade(
|
||||||
|
request as IncomingMessage,
|
||||||
|
socket as Duplex,
|
||||||
|
head as Buffer,
|
||||||
|
).catch((error) => {
|
||||||
|
pluginLogger.error(
|
||||||
|
"Plugin WebSocket upgrade failed",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_ws_upgrade" },
|
||||||
|
);
|
||||||
|
reject(socket as Duplex, 500, "Internal Server Error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPluginWsRoute(
|
||||||
|
pluginId: string,
|
||||||
|
path: string,
|
||||||
|
handler: PluginWebSocketHandler,
|
||||||
|
declared: readonly string[],
|
||||||
|
options: PluginWebSocketOptions = {},
|
||||||
|
): () => void {
|
||||||
|
const routeKey = key(pluginId, path);
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
|
||||||
|
routes.set(routeKey, {
|
||||||
|
pluginId,
|
||||||
|
path: normalized,
|
||||||
|
options,
|
||||||
|
declared,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
servers.set(routeKey, new WebSocketServer({ noServer: true }));
|
||||||
|
|
||||||
|
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
|
||||||
|
operation: "plugin_ws_mount",
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => disposeRoute(routeKey, pluginId, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPluginWsUpgrade(
|
||||||
|
pluginId: string,
|
||||||
|
path: string,
|
||||||
|
handler: RawUpgradeHandler,
|
||||||
|
declared: readonly string[],
|
||||||
|
options: PluginWebSocketOptions = {},
|
||||||
|
): () => void {
|
||||||
|
const routeKey = key(pluginId, path);
|
||||||
|
const normalized = normalizePath(path);
|
||||||
|
|
||||||
|
routes.set(routeKey, {
|
||||||
|
pluginId,
|
||||||
|
path: normalized,
|
||||||
|
options,
|
||||||
|
declared,
|
||||||
|
rawHandler: handler,
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginLogger.info(`Mounted raw upgrade /plugin-ws/${pluginId}${normalized}`, {
|
||||||
|
operation: "plugin_ws_mount",
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => disposeRoute(routeKey, pluginId, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeRoute(routeKey: string, pluginId: string, path: string): void {
|
||||||
|
routes.delete(routeKey);
|
||||||
|
|
||||||
|
const server = servers.get(routeKey);
|
||||||
|
if (server) {
|
||||||
|
servers.delete(routeKey);
|
||||||
|
// Close the clients first: server.close() waits for them to drain, and a
|
||||||
|
// live terminal would otherwise keep the route alive after deactivate.
|
||||||
|
for (const client of server.clients) {
|
||||||
|
try {
|
||||||
|
client.close(1001, "Plugin disabled");
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {
|
||||||
|
// Closing twice is not an error worth surfacing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginLogger.info(`Unmounted /plugin-ws/${pluginId}${path}`, {
|
||||||
|
operation: "plugin_ws_unmount",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredWsRoutes(): string[] {
|
||||||
|
return [...routes.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetPluginWebSockets(): void {
|
||||||
|
for (const routeKey of [...routes.keys()]) {
|
||||||
|
const route = routes.get(routeKey);
|
||||||
|
if (route) disposeRoute(routeKey, route.pluginId, route.path);
|
||||||
|
}
|
||||||
|
routes.clear();
|
||||||
|
servers.clear();
|
||||||
|
attachedServers.clear();
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* /plugin-api is mounted behind authenticateJWT in database.ts, the same way
|
|
||||||
* every other router is gated, so an unauthenticated caller can never reach
|
|
||||||
* plugin code.
|
|
||||||
*
|
|
||||||
* The dispatcher itself has no routes registered in A1: the worker HTTP
|
|
||||||
* bridge was removed with the worker tier and A4 rebuilds routing on top of
|
|
||||||
* the SDK. What still has to hold, and what this covers, is that the auth
|
|
||||||
* gate sits in front of the mount and that an unknown plugin id is a 404
|
|
||||||
* rather than a crash.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import express from "express";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import type { Server } from "node:http";
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
|
||||||
validToken: "valid-token",
|
|
||||||
userId: "user-1",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../utils/logger.js", () => ({
|
|
||||||
databaseLogger: {
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pluginApi = await import("../../../database/routes/plugin-api-routes.js");
|
|
||||||
|
|
||||||
/** Stands in for AuthManager's middleware, with the same 401 behaviour. */
|
|
||||||
function authenticateJWT(
|
|
||||||
req: express.Request,
|
|
||||||
res: express.Response,
|
|
||||||
next: express.NextFunction,
|
|
||||||
): void {
|
|
||||||
const header = req.headers.authorization;
|
|
||||||
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
||||||
|
|
||||||
if (token !== state.validToken) {
|
|
||||||
res.status(401).json({ error: "Unauthorized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
(req as express.Request & { userId?: string }).userId = state.userId;
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
let server: Server | null = null;
|
|
||||||
|
|
||||||
async function startServer(): Promise<string> {
|
|
||||||
const app = express();
|
|
||||||
// The same order database.ts uses: the gate, then the dispatcher.
|
|
||||||
app.use("/plugin-api", authenticateJWT, pluginApi.default);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server = app.listen(0, "127.0.0.1", () => {
|
|
||||||
const { port } = server!.address() as AddressInfo;
|
|
||||||
resolve(`http://127.0.0.1:${port}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (server) {
|
|
||||||
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
|
||||||
server = null;
|
|
||||||
}
|
|
||||||
for (const id of pluginApi.getRegisteredPluginIds()) {
|
|
||||||
pluginApi.unregisterPluginRouter(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("/plugin-api authentication", () => {
|
|
||||||
it("rejects a request with no token", async () => {
|
|
||||||
const base = await startServer();
|
|
||||||
|
|
||||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a request with the wrong token", async () => {
|
|
||||||
const base = await startServer();
|
|
||||||
|
|
||||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
|
||||||
headers: { Authorization: "Bearer nope" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Past the gate, an id nothing registered is a plain 404. The auth check
|
|
||||||
// runs first either way, so a caller cannot use the difference between 401
|
|
||||||
// and 404 to find out which plugins are installed.
|
|
||||||
it("returns 404 for an unregistered plugin id when authenticated", async () => {
|
|
||||||
const base = await startServer();
|
|
||||||
|
|
||||||
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
|
||||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispatches to a registered router only when authenticated", async () => {
|
|
||||||
const router = express.Router();
|
|
||||||
router.get("/thing", (req, res) => {
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
userId: (req as express.Request & { userId?: string }).userId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
pluginApi.registerPluginRouter("sample-plugin", router);
|
|
||||||
|
|
||||||
const base = await startServer();
|
|
||||||
|
|
||||||
const unauthorized = await fetch(`${base}/plugin-api/sample-plugin/thing`);
|
|
||||||
expect(unauthorized.status).toBe(401);
|
|
||||||
|
|
||||||
const authorized = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
|
||||||
headers: { Authorization: `Bearer ${state.validToken}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(authorized.status).toBe(200);
|
|
||||||
// The user id the plugin sees is the one the gate verified, never one the
|
|
||||||
// caller supplied.
|
|
||||||
expect(await authorized.json()).toEqual({
|
|
||||||
ok: true,
|
|
||||||
userId: state.userId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -56,7 +56,7 @@ vi.mock("../../../utils/audit-logger.js", () => ({
|
|||||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||||
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
|
createGuacamoleJoinToken: (id: string, readOnly: boolean) =>
|
||||||
`join:${id}:${readOnly}`,
|
`join:${id}:${readOnly}`,
|
||||||
}));
|
}));
|
||||||
@@ -646,7 +646,7 @@ describe("collab room routes", () => {
|
|||||||
await present(roomId, "s1");
|
await present(roomId, "s1");
|
||||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||||
protocol: "ssh",
|
protocol: "ssh",
|
||||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||||
});
|
});
|
||||||
state.liveOwned.set("g1", "host-1");
|
state.liveOwned.set("g1", "host-1");
|
||||||
await present(roomId, "g1", "vnc");
|
await present(roomId, "g1", "vnc");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../../database/routes/guacamole-dispatch.js", () => ({
|
vi.mock("../../../hosts/guacamole-sessions.js", () => ({
|
||||||
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
getGuacamoleSessionInfo: (guacamoleConnectionId: string) =>
|
||||||
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||||
createGuacamoleJoinToken: (
|
createGuacamoleJoinToken: (
|
||||||
@@ -364,7 +364,7 @@ describe("GET /session-sharing/resolve/:linkToken", () => {
|
|||||||
expect(res.jsonBody).toEqual({
|
expect(res.jsonBody).toEqual({
|
||||||
protocol: "ssh",
|
protocol: "ssh",
|
||||||
permissionLevel: "read-only",
|
permissionLevel: "read-only",
|
||||||
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* The terminal WS server's start/stop lifecycle, which the ssh-terminal plugin
|
* The terminal WS server's lifecycle, which the ssh-terminal plugin drives.
|
||||||
* drives. The point of this test is the observable consequence of disabling
|
*
|
||||||
* the plugin: the port actually stops listening.
|
* There is no terminal port any more: core serves the socket at
|
||||||
|
* /plugin-ws/ssh-terminal/terminal and the plugin hands each upgrade to the
|
||||||
|
* WebSocketServer this module owns. So the thing worth asserting changed with
|
||||||
|
* it. It used to be "the port stops listening"; it is now "the module binds
|
||||||
|
* nothing on import, and disabling the plugin closes the live sessions", which
|
||||||
|
* is what disabling a transport owner has to mean when core owns the socket.
|
||||||
*
|
*
|
||||||
* The terminal module is heavy (it pulls in the whole SSH stack), so this
|
* The terminal module is heavy (it pulls in the whole SSH stack), so this
|
||||||
* exercises only the lifecycle functions, on a non-default port.
|
* exercises only the lifecycle surface.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import net from "node:net";
|
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
createCurrentHostResolutionRepository: () => ({}),
|
createCurrentHostResolutionRepository: () => ({}),
|
||||||
@@ -18,65 +22,29 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentSessionRecordingRepository: () => ({}),
|
createCurrentSessionRecordingRepository: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const TEST_PORT = 39002;
|
|
||||||
|
|
||||||
function isListening(port: number): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const socket = net.connect({ port, host: "127.0.0.1" });
|
|
||||||
socket.once("connect", () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve(true);
|
|
||||||
});
|
|
||||||
socket.once("error", () => resolve(false));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("terminal server lifecycle", () => {
|
describe("terminal server lifecycle", () => {
|
||||||
afterEach(async () => {
|
it("binds no port of its own", async () => {
|
||||||
|
const terminal = await import("../../../hosts/terminal/index.js");
|
||||||
|
|
||||||
|
// The old module exported a port and a listen() call. Both are gone, and
|
||||||
|
// their absence is the contract: a plugin that binds its own port is
|
||||||
|
// exactly what A4 removed.
|
||||||
|
expect("TERMINAL_WS_PORT" in terminal).toBe(false);
|
||||||
|
expect("startTerminalServer" in terminal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an upgrade handler for core to route to", async () => {
|
||||||
|
const { handleTerminalUpgrade } =
|
||||||
|
await import("../../../hosts/terminal/index.js");
|
||||||
|
|
||||||
|
expect(typeof handleTerminalUpgrade).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent on stop, so a double deactivate is safe", async () => {
|
||||||
const { stopTerminalServer } =
|
const { stopTerminalServer } =
|
||||||
await import("../../../hosts/terminal/index.js");
|
await import("../../../hosts/terminal/index.js");
|
||||||
await stopTerminalServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not listen until it is started", async () => {
|
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||||
await import("../../../hosts/terminal/index.js");
|
await expect(stopTerminalServer()).resolves.toBeUndefined();
|
||||||
// Importing the module must not bind the port: the plugin decides that.
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listens after start and frees the port after stop", async () => {
|
|
||||||
const { startTerminalServer, stopTerminalServer } =
|
|
||||||
await import("../../../hosts/terminal/index.js");
|
|
||||||
|
|
||||||
await startTerminalServer(TEST_PORT);
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(true);
|
|
||||||
|
|
||||||
await stopTerminalServer();
|
|
||||||
// This is what "disabling the plugin" has to mean for a transport owner.
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent on both ends", async () => {
|
|
||||||
const { startTerminalServer, stopTerminalServer } =
|
|
||||||
await import("../../../hosts/terminal/index.js");
|
|
||||||
|
|
||||||
await startTerminalServer(TEST_PORT);
|
|
||||||
await startTerminalServer(TEST_PORT);
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(true);
|
|
||||||
|
|
||||||
await stopTerminalServer();
|
|
||||||
await stopTerminalServer();
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can be restarted, so re-enabling the plugin works", async () => {
|
|
||||||
const { startTerminalServer, stopTerminalServer } =
|
|
||||||
await import("../../../hosts/terminal/index.js");
|
|
||||||
|
|
||||||
await startTerminalServer(TEST_PORT);
|
|
||||||
await stopTerminalServer();
|
|
||||||
await startTerminalServer(TEST_PORT);
|
|
||||||
|
|
||||||
expect(await isListening(TEST_PORT)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* ctx.http: the one way a plugin serves HTTP.
|
||||||
|
*
|
||||||
|
* Everything here is about the middleware core puts in front of a plugin's
|
||||||
|
* routes, because that is what moving off the dispatcher shims bought: auth by
|
||||||
|
* default, a truthful 503 while disabled, a capability that can be revoked
|
||||||
|
* without a restart, and streaming that still streams.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { Server } from "node:http";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
validToken: "valid-token",
|
||||||
|
userId: "user-1",
|
||||||
|
granted: new Set<string>(["network:serve"]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
databaseLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
||||||
|
if (token !== state.validToken) {
|
||||||
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(req as express.Request & { userId?: string }).userId = state.userId;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/permissions.js", () => ({
|
||||||
|
hasCapability: async (
|
||||||
|
_pluginId: string,
|
||||||
|
capability: string,
|
||||||
|
declared: readonly string[],
|
||||||
|
) => declared.includes(capability) && state.granted.has(capability),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn() }));
|
||||||
|
|
||||||
|
const http = await import("../../plugins/http.js");
|
||||||
|
const pluginApi = (await import("../../database/routes/plugin-api-routes.js"))
|
||||||
|
.default;
|
||||||
|
|
||||||
|
function manifest(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "sample-plugin",
|
||||||
|
name: "Sample Plugin",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["network:serve"],
|
||||||
|
...overrides,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: Server | null = null;
|
||||||
|
|
||||||
|
async function startServer(): Promise<string> {
|
||||||
|
const app = express();
|
||||||
|
// Exactly how database.ts mounts it: no auth in front, because the plugin
|
||||||
|
// router brings its own.
|
||||||
|
app.use("/plugin-api", pluginApi);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server = app.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = server!.address() as AddressInfo;
|
||||||
|
resolve(`http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authed = { Authorization: `Bearer ${state.validToken}` };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
http.resetPluginHttp();
|
||||||
|
state.granted = new Set(["network:serve"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
http.resetPluginHttp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http mounting", () => {
|
||||||
|
it("serves a plugin's routes once it registers a router", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s a plugin id that never registered a router", async () => {
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
const response = await fetch(`${base}/plugin-api/nobody/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmounts on deactivate, so a disabled plugin stops serving", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
http.unregisterPluginHttp("sample-plugin");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http authentication", () => {
|
||||||
|
it("requires authentication by default", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
expect((await fetch(`${base}/plugin-api/sample-plugin/thing`)).status).toBe(
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: { Authorization: "Bearer nope" },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a declared public path without a token", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
options: { public: ["/webhook/:token"] },
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.post("/webhook/:token", (req, res) =>
|
||||||
|
res.json({ token: req.params.token }),
|
||||||
|
);
|
||||||
|
router.get("/private", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
const open = await fetch(
|
||||||
|
`${base}/plugin-api/sample-plugin/webhook/abc123`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(open.status).toBe(200);
|
||||||
|
expect(await open.json()).toEqual({ token: "abc123" });
|
||||||
|
|
||||||
|
// Declaring one route public must not open the rest of the plugin.
|
||||||
|
expect(
|
||||||
|
(await fetch(`${base}/plugin-api/sample-plugin/private`)).status,
|
||||||
|
).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a public parameter swallow extra path segments", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
options: { public: ["/webhook/:token"] },
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/webhook/:token/secret", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${base}/plugin-api/sample-plugin/webhook/abc/secret`,
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the plugin the user core authenticated, not one the caller named", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/who", (req, res) =>
|
||||||
|
res.json({
|
||||||
|
userId: (req as express.Request & { userId?: string }).userId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/who`, {
|
||||||
|
headers: { ...authed, "x-user-id": "attacker" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await response.json()).toEqual({ userId: state.userId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http capability", () => {
|
||||||
|
it("refuses to build a router without network:serve declared", () => {
|
||||||
|
expect(() =>
|
||||||
|
http.createPluginRouter({
|
||||||
|
manifest: manifest({ capabilities: [] }),
|
||||||
|
reportError: () => {},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
// Declaration is checked in ctx.ts; the grant is checked per request below.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403s every request when the grant is revoked", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
|
||||||
|
// Revoked at runtime: no restart, and the next request is refused.
|
||||||
|
state.granted.delete("network:serve");
|
||||||
|
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http while the plugin is disabled", () => {
|
||||||
|
it("answers 503 rather than 404", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
router.get("/thing", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
// "Installed but not running" is a different fact from "no such plugin",
|
||||||
|
// and a caller can act on the difference.
|
||||||
|
http.setPluginEnabledCheck(() => false);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/thing`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http errors", () => {
|
||||||
|
it("reports a throwing route without leaking a stack", async () => {
|
||||||
|
const reported: unknown[] = [];
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: (error) => reported.push(error),
|
||||||
|
});
|
||||||
|
router.get("/boom", () => {
|
||||||
|
throw new Error("secret internal detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/boom`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const body = await response.text();
|
||||||
|
expect(body).not.toContain("secret internal detail");
|
||||||
|
// Counted against the plugin's error budget.
|
||||||
|
expect(reported).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.http streaming", () => {
|
||||||
|
it("streams an SSE response as it is produced", async () => {
|
||||||
|
const router = http.createPluginRouter({
|
||||||
|
manifest: manifest(),
|
||||||
|
reportError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
let release: (() => void) | null = null;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/stream", async (_req, res) => {
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.write("event: progress\ndata: 1\n\n");
|
||||||
|
// The first chunk has to arrive before the handler finishes, or this is
|
||||||
|
// buffering rather than streaming.
|
||||||
|
await gate;
|
||||||
|
res.write("event: result\ndata: done\n\n");
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const response = await fetch(`${base}/plugin-api/sample-plugin/stream`, {
|
||||||
|
headers: authed,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
|
||||||
|
const reader = response.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
const first = await reader.read();
|
||||||
|
expect(decoder.decode(first.value)).toContain("data: 1");
|
||||||
|
|
||||||
|
release!();
|
||||||
|
|
||||||
|
let rest = "";
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
rest += decoder.decode(chunk.value);
|
||||||
|
}
|
||||||
|
expect(rest).toContain("data: done");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* ctx.ws: the one way a plugin serves WebSockets.
|
||||||
|
*
|
||||||
|
* The properties that matter are the ones the per-plugin ports used to leave
|
||||||
|
* to each plugin: an upgrade is authenticated before any plugin code runs, a
|
||||||
|
* route that no longer exists refuses cleanly, and disabling a plugin actually
|
||||||
|
* closes its live sockets rather than just unrouting new ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
validToken: "valid-token",
|
||||||
|
userId: "user-1",
|
||||||
|
granted: new Set<string>(["network:serve"]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
verifyJWTToken: async (token: string) =>
|
||||||
|
token === state.validToken ? { userId: state.userId } : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../plugins/permissions.js", () => ({
|
||||||
|
hasCapability: async (
|
||||||
|
_pluginId: string,
|
||||||
|
capability: string,
|
||||||
|
declared: readonly string[],
|
||||||
|
) => declared.includes(capability) && state.granted.has(capability),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ws = await import("../../plugins/ws.js");
|
||||||
|
|
||||||
|
const DECLARED = ["network:serve"] as const;
|
||||||
|
|
||||||
|
let server: http.Server | null = null;
|
||||||
|
let baseUrl = "";
|
||||||
|
|
||||||
|
async function startServer(): Promise<string> {
|
||||||
|
const instance = http.createServer((_req, res) => {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
ws.attachPluginWebSockets(instance);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server = instance;
|
||||||
|
instance.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = instance.address() as AddressInfo;
|
||||||
|
baseUrl = `ws://127.0.0.1:${port}`;
|
||||||
|
resolve(baseUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves with "open" or the HTTP status the upgrade was refused with. */
|
||||||
|
function connect(
|
||||||
|
url: string,
|
||||||
|
protocols: string[] = [],
|
||||||
|
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new WebSocket(url, protocols);
|
||||||
|
socket.once("open", () => resolve({ outcome: "open", socket }));
|
||||||
|
socket.once("unexpected-response", (_req, res) =>
|
||||||
|
resolve({ outcome: res.statusCode ?? 0 }),
|
||||||
|
);
|
||||||
|
socket.once("error", () => resolve({ outcome: 0 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authProtocols(token: string): string[] {
|
||||||
|
return [`termix.jwt.${token}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ws.resetPluginWebSockets();
|
||||||
|
state.granted = new Set(["network:serve"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
ws.resetPluginWebSockets();
|
||||||
|
if (server) {
|
||||||
|
await new Promise((resolve) => server!.close(() => resolve(undefined)));
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.ws routing", () => {
|
||||||
|
it("serves a registered route to an authenticated client", async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
ws.registerPluginWsRoute(
|
||||||
|
"sample-plugin",
|
||||||
|
"/socket",
|
||||||
|
({ userId, socket }) => {
|
||||||
|
seen.push(userId);
|
||||||
|
(socket as WebSocket).send("hello");
|
||||||
|
},
|
||||||
|
DECLARED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/socket`,
|
||||||
|
authProtocols(state.validToken),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("open");
|
||||||
|
expect(seen).toEqual([state.userId]);
|
||||||
|
if (result.outcome === "open") result.socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s a path nothing registered", async () => {
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
const result = await connect(`${base}/plugin-ws/sample-plugin/missing`);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-plugin upgrades to whoever else is listening", async () => {
|
||||||
|
let claimedByCore = false;
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
// A second listener standing in for a core WS server. If the plugin
|
||||||
|
// handler answered /ssh/websocket/ itself this would never fire, and the
|
||||||
|
// socket would be stolen from core rather than passed on.
|
||||||
|
server!.on("upgrade", (_req, socket) => {
|
||||||
|
claimedByCore = true;
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
await connect(`${base}/ssh/websocket/`);
|
||||||
|
|
||||||
|
expect(claimedByCore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.ws authentication", () => {
|
||||||
|
it("rejects an upgrade with no token", async () => {
|
||||||
|
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(`${base}/plugin-ws/sample-plugin/socket`);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an upgrade with a bad token", async () => {
|
||||||
|
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/socket`,
|
||||||
|
authProtocols("nope"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a public route without a token", async () => {
|
||||||
|
ws.registerPluginWsRoute(
|
||||||
|
"sample-plugin",
|
||||||
|
"/guest",
|
||||||
|
({ socket }) => (socket as WebSocket).send("hi"),
|
||||||
|
DECLARED,
|
||||||
|
{ public: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(`${base}/plugin-ws/sample-plugin/guest`);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("open");
|
||||||
|
if (result.outcome === "open") result.socket.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.ws capability", () => {
|
||||||
|
it("refuses the upgrade when network:serve is not granted", async () => {
|
||||||
|
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
|
||||||
|
state.granted.delete("network:serve");
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/socket`,
|
||||||
|
authProtocols(state.validToken),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.ws disposal", () => {
|
||||||
|
it("closes live sockets and stops routing on deactivate", async () => {
|
||||||
|
const dispose = ws.registerPluginWsRoute(
|
||||||
|
"sample-plugin",
|
||||||
|
"/socket",
|
||||||
|
() => {},
|
||||||
|
DECLARED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
const result = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/socket`,
|
||||||
|
authProtocols(state.validToken),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe("open");
|
||||||
|
|
||||||
|
const closed = new Promise<number>((resolve) => {
|
||||||
|
if (result.outcome === "open") {
|
||||||
|
result.socket.once("close", (code) => resolve(code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dispose();
|
||||||
|
|
||||||
|
// A live terminal must not outlive the plugin that owns it.
|
||||||
|
expect(await closed).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const afterDispose = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/socket`,
|
||||||
|
authProtocols(state.validToken),
|
||||||
|
);
|
||||||
|
expect(afterDispose.outcome).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.ws raw upgrades", () => {
|
||||||
|
it("authenticates before handing the socket to the plugin", async () => {
|
||||||
|
const handled: string[] = [];
|
||||||
|
ws.registerPluginWsUpgrade(
|
||||||
|
"sample-plugin",
|
||||||
|
"/raw",
|
||||||
|
(_request, socket, _head, userId) => {
|
||||||
|
handled.push(userId);
|
||||||
|
(socket as { destroy: () => void }).destroy();
|
||||||
|
},
|
||||||
|
DECLARED,
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = await startServer();
|
||||||
|
|
||||||
|
const unauthenticated = await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/raw`,
|
||||||
|
);
|
||||||
|
expect(unauthenticated.outcome).toBe(401);
|
||||||
|
// The plugin never saw the unauthenticated attempt.
|
||||||
|
expect(handled).toEqual([]);
|
||||||
|
|
||||||
|
await connect(
|
||||||
|
`${base}/plugin-ws/sample-plugin/raw`,
|
||||||
|
authProtocols(state.validToken),
|
||||||
|
);
|
||||||
|
expect(handled).toEqual([state.userId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePluginWsUrl", () => {
|
||||||
|
it("only claims well-formed plugin socket paths", () => {
|
||||||
|
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console")).toEqual({
|
||||||
|
pluginId: "docker",
|
||||||
|
path: "/console",
|
||||||
|
});
|
||||||
|
expect(ws.parsePluginWsUrl("/plugin-ws/docker/console?x=1")).toEqual({
|
||||||
|
pluginId: "docker",
|
||||||
|
path: "/console",
|
||||||
|
});
|
||||||
|
expect(ws.parsePluginWsUrl("/ssh/websocket/")).toBeNull();
|
||||||
|
expect(ws.parsePluginWsUrl("/plugin-ws/docker")).toBeNull();
|
||||||
|
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* dispatchLoginEvent used to POST to the metrics service on localhost:30005
|
||||||
|
* with an internal auth token. Host metrics is a plugin now and has no port,
|
||||||
|
* so it publishes on the plugin event bus and the plugin subscribes.
|
||||||
|
*
|
||||||
|
* What is asserted changed with it: the payload a subscriber receives, and
|
||||||
|
* that a throwing subscriber cannot fail the login that caused the event.
|
||||||
|
*/
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
describe("dispatchLoginEvent", () => {
|
describe("dispatchLoginEvent", () => {
|
||||||
@@ -8,37 +17,24 @@ describe("dispatchLoginEvent", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a rejected metrics-service request", async () => {
|
it("publishes the login details a subscriber needs", async () => {
|
||||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
const received: unknown[] = [];
|
||||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => {
|
||||||
} as never);
|
received.push(payload);
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
});
|
||||||
new Response('{"error":"Missing authentication token"}', {
|
|
||||||
status: 401,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
try {
|
||||||
|
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
expect(warn).toHaveBeenCalledWith(
|
expect(received).toEqual([
|
||||||
"Failed to dispatch login event",
|
{ hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" },
|
||||||
expect.objectContaining({
|
]);
|
||||||
operation: "login_event_dispatch_error",
|
|
||||||
hostId: 7,
|
|
||||||
error:
|
|
||||||
'Metrics service returned 401: {"error":"Missing authentication token"}',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not log a warning when the metrics service accepts the event", async () => {
|
it("does not log a warning on the happy path", async () => {
|
||||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
|
||||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
|
||||||
} as never);
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
||||||
new Response('{"ok":true}', { status: 200 }),
|
|
||||||
);
|
|
||||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||||
@@ -46,49 +42,27 @@ describe("dispatchLoginEvent", () => {
|
|||||||
expect(warn).not.toHaveBeenCalled();
|
expect(warn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends the internal auth token and login details the metrics service expects", async () => {
|
it("never sends the event over the network", async () => {
|
||||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
|
||||||
} as never);
|
|
||||||
const fetchSpy = vi
|
|
||||||
.spyOn(globalThis, "fetch")
|
|
||||||
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
|
||||||
|
|
||||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
// The whole point of the move: no service-to-service HTTP call, and no
|
||||||
"http://localhost:30005/internal/login-alert",
|
// internal shared secret to carry on it.
|
||||||
expect.objectContaining({
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
method: "POST",
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
"x-internal-auth": "internal-token",
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({
|
|
||||||
hostId: 42,
|
|
||||||
userId: "user-1",
|
|
||||||
sshUser: "root",
|
|
||||||
fromIp: "10.0.0.5",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs a warning if the fetch itself throws, instead of propagating", async () => {
|
it("does not propagate a subscriber's throw to the caller", async () => {
|
||||||
vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({
|
const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => {
|
||||||
getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"),
|
throw new Error("subscriber exploded");
|
||||||
} as never);
|
});
|
||||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
|
||||||
new Error("connect ECONNREFUSED"),
|
|
||||||
);
|
|
||||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
await expect(
|
try {
|
||||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
await expect(
|
||||||
).resolves.toBeUndefined();
|
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
expect(warn).toHaveBeenCalledWith(
|
} finally {
|
||||||
"Failed to dispatch login event",
|
unsubscribe();
|
||||||
expect.objectContaining({ hostId: 1 }),
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { SystemCrypto } from "./system-crypto.js";
|
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||||
import { sshLogger } from "./logger.js";
|
import { sshLogger } from "./logger.js";
|
||||||
|
|
||||||
const METRICS_SERVICE_URL = "http://localhost:30005";
|
/**
|
||||||
|
* Announces an SSH login so anything watching can react to it.
|
||||||
|
*
|
||||||
|
* Was an HTTP POST to the metrics service on localhost:30005. Host metrics is
|
||||||
|
* a plugin now and has no port, so this publishes on the plugin event bus and
|
||||||
|
* the plugin subscribes. Fire and forget: a subscriber that throws must never
|
||||||
|
* fail the login that caused it.
|
||||||
|
*/
|
||||||
export async function dispatchLoginEvent(
|
export async function dispatchLoginEvent(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -10,24 +16,7 @@ export async function dispatchLoginEvent(
|
|||||||
fromIp: string,
|
fromIp: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const token = await SystemCrypto.getInstance().getInternalAuthToken();
|
pluginEvents.emit(TOPICS.hostLogin, { hostId, userId, sshUser, fromIp });
|
||||||
const response = await fetch(
|
|
||||||
`${METRICS_SERVICE_URL}/internal/login-alert`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-internal-auth": token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ hostId, userId, sshUser, fromIp }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const details = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`Metrics service returned ${response.status}${details ? `: ${details}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.warn("Failed to dispatch login event", {
|
sshLogger.warn("Failed to dispatch login event", {
|
||||||
operation: "login_event_dispatch_error",
|
operation: "login_event_dispatch_error",
|
||||||
|
|||||||
@@ -30,18 +30,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
|||||||
url: "http://localhost:30004",
|
url: "http://localhost:30004",
|
||||||
description: "SSH file manager server",
|
description: "SSH file manager server",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: "http://localhost:30005",
|
|
||||||
description: "Server statistics and monitoring server",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: "http://localhost:30006",
|
url: "http://localhost:30006",
|
||||||
description: "Dashboard server",
|
description: "Dashboard server",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
url: "http://localhost:30007",
|
|
||||||
description: "Docker management server",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: "http://localhost:30011",
|
url: "http://localhost:30011",
|
||||||
description: "Serial connection server",
|
description: "Serial connection server",
|
||||||
|
|||||||
+29
-20
@@ -52,7 +52,7 @@ export interface AiStatus {
|
|||||||
|
|
||||||
export async function getAiStatus(): Promise<AiStatus> {
|
export async function getAiStatus(): Promise<AiStatus> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/ai/status")).data;
|
return (await authApi.get("/plugin-api/ai/status")).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get AI status");
|
throw handleApiError(error, "get AI status");
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ export async function getAiStatus(): Promise<AiStatus> {
|
|||||||
|
|
||||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/ai/providers")).data.providers;
|
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "list AI providers");
|
throw handleApiError(error, "list AI providers");
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,8 @@ export async function createAiProvider(input: {
|
|||||||
defaultModel?: string | null;
|
defaultModel?: string | null;
|
||||||
}): Promise<AiProvider> {
|
}): Promise<AiProvider> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/ai/providers", input)).data.provider;
|
return (await authApi.post("/plugin-api/ai/providers", input)).data
|
||||||
|
.provider;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create AI provider");
|
throw handleApiError(error, "create AI provider");
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,8 @@ export async function updateAiProvider(
|
|||||||
}>,
|
}>,
|
||||||
): Promise<AiProvider> {
|
): Promise<AiProvider> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.patch(`/ai/providers/${id}`, input)).data.provider;
|
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
|
||||||
|
.provider;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update AI provider");
|
throw handleApiError(error, "update AI provider");
|
||||||
}
|
}
|
||||||
@@ -99,7 +101,7 @@ export async function updateAiProvider(
|
|||||||
|
|
||||||
export async function deleteAiProvider(id: number): Promise<void> {
|
export async function deleteAiProvider(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.delete(`/ai/providers/${id}`);
|
await authApi.delete(`/plugin-api/ai/providers/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete AI provider");
|
throw handleApiError(error, "delete AI provider");
|
||||||
}
|
}
|
||||||
@@ -120,7 +122,7 @@ export async function probeAiModels(input: {
|
|||||||
warning?: string;
|
warning?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post("/ai/probe-models", input)).data;
|
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "detect models");
|
throw handleApiError(error, "detect models");
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,8 @@ export async function probeAiModels(input: {
|
|||||||
|
|
||||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get(`/ai/providers/${id}/models`)).data.models;
|
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
|
||||||
|
.models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "list provider models");
|
throw handleApiError(error, "list provider models");
|
||||||
}
|
}
|
||||||
@@ -136,7 +139,8 @@ export async function getAiProviderModels(id: number): Promise<string[]> {
|
|||||||
|
|
||||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/ai/conversations")).data.conversations;
|
return (await authApi.get("/plugin-api/ai/conversations")).data
|
||||||
|
.conversations;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "list AI conversations");
|
throw handleApiError(error, "list AI conversations");
|
||||||
}
|
}
|
||||||
@@ -148,7 +152,7 @@ export async function getAiConversation(id: number): Promise<{
|
|||||||
proposals: AiProposal[];
|
proposals: AiProposal[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get(`/ai/conversations/${id}`)).data;
|
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "load AI conversation");
|
throw handleApiError(error, "load AI conversation");
|
||||||
}
|
}
|
||||||
@@ -156,7 +160,7 @@ export async function getAiConversation(id: number): Promise<{
|
|||||||
|
|
||||||
export async function deleteAiConversation(id: number): Promise<void> {
|
export async function deleteAiConversation(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.delete(`/ai/conversations/${id}`);
|
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete AI conversation");
|
throw handleApiError(error, "delete AI conversation");
|
||||||
}
|
}
|
||||||
@@ -166,7 +170,7 @@ export async function applyAiProposal(
|
|||||||
id: number,
|
id: number,
|
||||||
): Promise<{ success: boolean; summary: string }> {
|
): Promise<{ success: boolean; summary: string }> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.post(`/ai/proposals/${id}/apply`)).data;
|
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "apply AI proposal");
|
throw handleApiError(error, "apply AI proposal");
|
||||||
}
|
}
|
||||||
@@ -174,7 +178,7 @@ export async function applyAiProposal(
|
|||||||
|
|
||||||
export async function rejectAiProposal(id: number): Promise<void> {
|
export async function rejectAiProposal(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await authApi.post(`/ai/proposals/${id}/reject`);
|
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "reject AI proposal");
|
throw handleApiError(error, "reject AI proposal");
|
||||||
}
|
}
|
||||||
@@ -191,10 +195,13 @@ export async function markAiProposalRunInTerminal(
|
|||||||
): Promise<{ success: boolean; summary: string }> {
|
): Promise<{ success: boolean; summary: string }> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
await authApi.post(`/ai/proposals/${id}/mark-run-in-terminal`, {
|
await authApi.post(
|
||||||
hostId,
|
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
|
||||||
summary,
|
{
|
||||||
})
|
hostId,
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
)
|
||||||
).data;
|
).data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "mark AI proposal run in terminal");
|
throw handleApiError(error, "mark AI proposal run in terminal");
|
||||||
@@ -205,7 +212,7 @@ export async function markAiProposalRunInTerminal(
|
|||||||
|
|
||||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
export async function getAiGloballyEnabled(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/ai/enabled")).data.enabled;
|
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get AI enabled setting");
|
throw handleApiError(error, "get AI enabled setting");
|
||||||
}
|
}
|
||||||
@@ -213,7 +220,8 @@ export async function getAiGloballyEnabled(): Promise<boolean> {
|
|||||||
|
|
||||||
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.patch("/ai/enabled", { enabled })).data.enabled;
|
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
|
||||||
|
.enabled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update AI enabled setting");
|
throw handleApiError(error, "update AI enabled setting");
|
||||||
}
|
}
|
||||||
@@ -221,7 +229,7 @@ export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
|||||||
|
|
||||||
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.get("/ai/private-endpoints")).data.hosts;
|
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "get AI endpoint allowlist");
|
throw handleApiError(error, "get AI endpoint allowlist");
|
||||||
}
|
}
|
||||||
@@ -231,7 +239,8 @@ export async function setAiPrivateEndpoints(
|
|||||||
hosts: string[],
|
hosts: string[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
return (await authApi.patch("/ai/private-endpoints", { hosts })).data.hosts;
|
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
|
||||||
|
.data.hosts;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update AI endpoint allowlist");
|
throw handleApiError(error, "update AI endpoint allowlist");
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-22
@@ -60,7 +60,7 @@ export type FleetPackageAction = "install" | "remove" | "upgrade-all";
|
|||||||
|
|
||||||
export async function listFleets(): Promise<FleetRow[]> {
|
export async function listFleets(): Promise<FleetRow[]> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/fleets");
|
const response = await authApi.get("/plugin-api/fleets");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch fleets");
|
throw handleApiError(error, "fetch fleets");
|
||||||
@@ -75,7 +75,7 @@ export async function createFleet(fleetData: {
|
|||||||
tagRules?: string[];
|
tagRules?: string[];
|
||||||
}): Promise<FleetRow> {
|
}): Promise<FleetRow> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/fleets", fleetData);
|
const response = await authApi.post("/plugin-api/fleets", fleetData);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create fleet");
|
throw handleApiError(error, "create fleet");
|
||||||
@@ -93,7 +93,10 @@ export async function updateFleet(
|
|||||||
},
|
},
|
||||||
): Promise<FleetRow> {
|
): Promise<FleetRow> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.patch(`/fleets/${fleetId}`, fleetData);
|
const response = await authApi.patch(
|
||||||
|
`/plugin-api/fleets/${fleetId}`,
|
||||||
|
fleetData,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update fleet");
|
throw handleApiError(error, "update fleet");
|
||||||
@@ -104,7 +107,7 @@ export async function deleteFleet(
|
|||||||
fleetId: number,
|
fleetId: number,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete(`/fleets/${fleetId}`);
|
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "delete fleet");
|
throw handleApiError(error, "delete fleet");
|
||||||
@@ -115,7 +118,7 @@ export async function getFleetMembers(
|
|||||||
fleetId: number,
|
fleetId: number,
|
||||||
): Promise<FleetMemberRow[]> {
|
): Promise<FleetMemberRow[]> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/fleets/${fleetId}/members`);
|
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch fleet members");
|
throw handleApiError(error, "fetch fleet members");
|
||||||
@@ -127,9 +130,12 @@ export async function addFleetMember(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/fleets/${fleetId}/members`, {
|
const response = await authApi.post(
|
||||||
hostId,
|
`/plugin-api/fleets/${fleetId}/members`,
|
||||||
});
|
{
|
||||||
|
hostId,
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "add fleet member");
|
throw handleApiError(error, "add fleet member");
|
||||||
@@ -142,7 +148,7 @@ export async function removeFleetMember(
|
|||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete(
|
const response = await authApi.delete(
|
||||||
`/fleets/${fleetId}/members/${hostId}`,
|
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,10 +162,13 @@ export async function runFleetCommand(
|
|||||||
inputValues?: Record<string, string>,
|
inputValues?: Record<string, string>,
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
): Promise<{ results: FleetHostResult[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/fleets/${fleetId}/execute`, {
|
const response = await authApi.post(
|
||||||
command,
|
`/plugin-api/fleets/${fleetId}/execute`,
|
||||||
...(inputValues ? { inputValues } : {}),
|
{
|
||||||
});
|
command,
|
||||||
|
...(inputValues ? { inputValues } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "run fleet command");
|
throw handleApiError(error, "run fleet command");
|
||||||
@@ -176,7 +185,7 @@ export async function pushFleetFile(
|
|||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
form.append("remotePath", remotePath);
|
form.append("remotePath", remotePath);
|
||||||
const response = await authApi.post(
|
const response = await authApi.post(
|
||||||
`/fleets/${fleetId}/transfer/push`,
|
`/plugin-api/fleets/${fleetId}/transfer/push`,
|
||||||
form,
|
form,
|
||||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
);
|
);
|
||||||
@@ -192,7 +201,7 @@ export async function pullFleetFile(
|
|||||||
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
|
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(
|
const response = await authApi.post(
|
||||||
`/fleets/${fleetId}/transfer/pull`,
|
`/plugin-api/fleets/${fleetId}/transfer/pull`,
|
||||||
{ remotePath },
|
{ remotePath },
|
||||||
{ responseType: "blob" },
|
{ responseType: "blob" },
|
||||||
);
|
);
|
||||||
@@ -217,7 +226,9 @@ export async function getFleetInventory(
|
|||||||
fleetId: number,
|
fleetId: number,
|
||||||
): Promise<FleetInventoryEntry[]> {
|
): Promise<FleetInventoryEntry[]> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/fleets/${fleetId}/inventory`);
|
const response = await authApi.get(
|
||||||
|
`/plugin-api/fleets/${fleetId}/inventory`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch fleet inventory");
|
throw handleApiError(error, "fetch fleet inventory");
|
||||||
@@ -228,7 +239,9 @@ export async function refreshFleetInventory(
|
|||||||
fleetId: number,
|
fleetId: number,
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
): Promise<{ results: FleetHostResult[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/fleets/${fleetId}/inventory`);
|
const response = await authApi.post(
|
||||||
|
`/plugin-api/fleets/${fleetId}/inventory`,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "refresh fleet inventory");
|
throw handleApiError(error, "refresh fleet inventory");
|
||||||
@@ -241,10 +254,13 @@ export async function runFleetPackageAction(
|
|||||||
packageName?: string,
|
packageName?: string,
|
||||||
): Promise<{ results: FleetHostResult[] }> {
|
): Promise<{ results: FleetHostResult[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/fleets/${fleetId}/packages`, {
|
const response = await authApi.post(
|
||||||
action,
|
`/plugin-api/fleets/${fleetId}/packages`,
|
||||||
...(packageName ? { package: packageName } : {}),
|
{
|
||||||
});
|
action,
|
||||||
|
...(packageName ? { package: packageName } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "run fleet package action");
|
throw handleApiError(error, "run fleet package action");
|
||||||
@@ -273,7 +289,10 @@ export async function shareFleet(
|
|||||||
hostResults: FleetShareHostResult[];
|
hostResults: FleetShareHostResult[];
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(`/fleets/${fleetId}/share`, shareData);
|
const response = await authApi.post(
|
||||||
|
`/plugin-api/fleets/${fleetId}/share`,
|
||||||
|
shareData,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "share fleet");
|
throw handleApiError(error, "share fleet");
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function executeSnippet(
|
|||||||
|
|
||||||
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/network-topology/");
|
const response = await authApi.get("/plugin-api/network-topology/");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch network topology");
|
throw handleApiError(error, "fetch network topology");
|
||||||
@@ -113,7 +113,9 @@ export async function saveNetworkTopology(
|
|||||||
topology: NetworkTopologyData,
|
topology: NetworkTopologyData,
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/network-topology/", { topology });
|
const response = await authApi.post("/plugin-api/network-topology/", {
|
||||||
|
topology,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "save network topology");
|
throw handleApiError(error, "save network topology");
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export async function discoverProxmoxGuests(
|
|||||||
): Promise<ProxmoxDiscoverResult> {
|
): Promise<ProxmoxDiscoverResult> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(
|
const response = await authApi.post(
|
||||||
"/proxmox/discover",
|
"/plugin-api/proxmox/discover",
|
||||||
{ hostId },
|
{ hostId },
|
||||||
{ timeout: 120000 },
|
{ timeout: 120000 },
|
||||||
);
|
);
|
||||||
@@ -208,7 +208,7 @@ export function discoverProxmoxGuestsStream(
|
|||||||
): () => void {
|
): () => void {
|
||||||
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
|
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
|
||||||
const source = new EventSource(
|
const source = new EventSource(
|
||||||
`${baseURL}/proxmox/discover/stream?hostId=${encodeURIComponent(
|
`${baseURL}/plugin-api/proxmox/discover/stream?hostId=${encodeURIComponent(
|
||||||
String(hostId),
|
String(hostId),
|
||||||
)}`,
|
)}`,
|
||||||
{ withCredentials: true },
|
{ withCredentials: true },
|
||||||
@@ -257,7 +257,7 @@ export async function syncProxmoxGuests(
|
|||||||
): Promise<ProxmoxSyncResult> {
|
): Promise<ProxmoxSyncResult> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post(
|
const response = await authApi.post(
|
||||||
"/proxmox/sync",
|
"/plugin-api/proxmox/sync",
|
||||||
{ hostId },
|
{ hostId },
|
||||||
{ timeout: 120000 },
|
{ timeout: 120000 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/sr
|
|||||||
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
||||||
import { getRoles } from "@/api/rbac-api";
|
import { getRoles } from "@/api/rbac-api";
|
||||||
import type { Role } from "@/main-axios";
|
import type { Role } from "@/main-axios";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||||
import { isElectron } from "@/lib/electron";
|
|
||||||
import { getErrorMessage } from "@/lib/error-message";
|
import { getErrorMessage } from "@/lib/error-message";
|
||||||
import {
|
import {
|
||||||
deleteCollabRoom,
|
deleteCollabRoom,
|
||||||
@@ -62,23 +61,11 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
|||||||
const PING_INTERVAL_MS = 30000;
|
const PING_INTERVAL_MS = 30000;
|
||||||
const POLL_FALLBACK_MS = 15000;
|
const POLL_FALLBACK_MS = 15000;
|
||||||
|
|
||||||
// Mirrors SharedSessionView's construction (dev/electron/prod); authentication
|
// The terminal socket the ssh-terminal plugin serves. Authentication rides on
|
||||||
// rides on the jwt cookie the way every terminal WS connection does.
|
// the jwt cookie the way every terminal WS connection does.
|
||||||
function roomEventsWsUrl(): string {
|
async function roomEventsWsUrl(): Promise<string | null> {
|
||||||
const isDev =
|
const target = await pluginWsUrl("ssh-terminal", "/terminal");
|
||||||
!isElectron() &&
|
return target?.url ?? null;
|
||||||
process.env.NODE_ENV === "development" &&
|
|
||||||
(window.location.port === "3000" ||
|
|
||||||
window.location.port === "5173" ||
|
|
||||||
window.location.port === "");
|
|
||||||
if (isDev) {
|
|
||||||
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
|
||||||
}
|
|
||||||
if (isElectron()) {
|
|
||||||
return "ws://127.0.0.1:30002";
|
|
||||||
}
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PresentDraft =
|
type PresentDraft =
|
||||||
@@ -194,13 +181,16 @@ export function CollabRoomTab({
|
|||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
};
|
};
|
||||||
const connect = () => {
|
const connect = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(roomEventsWsUrl());
|
const url = await roomEventsWsUrl();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!url) throw new Error("No terminal endpoint is available");
|
||||||
|
ws = new WebSocket(url);
|
||||||
} catch {
|
} catch {
|
||||||
startPolling();
|
startPolling();
|
||||||
reconnectTimer = setTimeout(connect, 5000);
|
reconnectTimer = setTimeout(() => void connect(), 5000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -246,12 +236,12 @@ export function CollabRoomTab({
|
|||||||
pingTimer = null;
|
pingTimer = null;
|
||||||
startPolling();
|
startPolling();
|
||||||
const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000);
|
const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000);
|
||||||
reconnectTimer = setTimeout(connect, delay);
|
reconnectTimer = setTimeout(() => void connect(), delay);
|
||||||
};
|
};
|
||||||
ws.onerror = () => ws?.close();
|
ws.onerror = () => ws?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
connect();
|
void connect();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (pingTimer) clearInterval(pingTimer);
|
if (pingTimer) clearInterval(pingTimer);
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
type ShareLinkErrorKind,
|
type ShareLinkErrorKind,
|
||||||
} from "@/api/session-sharing-api";
|
} from "@/api/session-sharing-api";
|
||||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||||
import { isElectron } from "@/lib/electron";
|
|
||||||
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
|
||||||
|
|
||||||
const PING_INTERVAL_MS = 30000;
|
const PING_INTERVAL_MS = 30000;
|
||||||
@@ -27,21 +26,9 @@ interface TerminalWsMessage {
|
|||||||
// embedded local backend -- joining a session hosted on someone else's
|
// embedded local backend -- joining a session hosted on someone else's
|
||||||
// remote server isn't supported from the desktop app today.
|
// remote server isn't supported from the desktop app today.
|
||||||
async function resolveTerminalWsBaseUrl(): Promise<string> {
|
async function resolveTerminalWsBaseUrl(): Promise<string> {
|
||||||
const isDev =
|
const target = await pluginWsUrl("ssh-terminal", "/terminal");
|
||||||
!isElectron() &&
|
if (!target) throw new Error("No terminal endpoint is available");
|
||||||
process.env.NODE_ENV === "development" &&
|
return target.url;
|
||||||
(window.location.port === "3000" ||
|
|
||||||
window.location.port === "5173" ||
|
|
||||||
window.location.port === "");
|
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
|
||||||
}
|
|
||||||
if (isElectron()) {
|
|
||||||
return "ws://127.0.0.1:30002";
|
|
||||||
}
|
|
||||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionParticipantInfo {
|
export interface SessionParticipantInfo {
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||||
import {
|
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||||
resolveConnectionOrigin,
|
|
||||||
buildOriginWsUrl,
|
|
||||||
} from "@/lib/connection-origin.ts";
|
|
||||||
import {
|
import {
|
||||||
getCookie,
|
getCookie,
|
||||||
isElectron,
|
isElectron,
|
||||||
@@ -1536,30 +1533,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
shouldNotReconnectRef.current = false;
|
shouldNotReconnectRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev =
|
|
||||||
!isElectron() &&
|
|
||||||
process.env.NODE_ENV === "development" &&
|
|
||||||
(window.location.port === "3000" ||
|
|
||||||
window.location.port === "5173" ||
|
|
||||||
window.location.port === "");
|
|
||||||
|
|
||||||
let baseWsUrl: string;
|
let baseWsUrl: string;
|
||||||
let wsProtocols: string[] = [];
|
let wsProtocols: string[] = [];
|
||||||
let outboundHostConfig = hostConfig;
|
let outboundHostConfig = hostConfig;
|
||||||
|
|
||||||
if (isDev) {
|
{
|
||||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
const origin = isElectron()
|
||||||
} else if (isElectron()) {
|
? await resolveConnectionOrigin({
|
||||||
const origin = await resolveConnectionOrigin({
|
connectionType: "ssh",
|
||||||
connectionType: "ssh",
|
connectionOrigin: hostConfig.connectionOrigin as
|
||||||
connectionOrigin: hostConfig.connectionOrigin as
|
"local" | "remote" | null | undefined,
|
||||||
"local" | "remote" | null | undefined,
|
})
|
||||||
});
|
: "local";
|
||||||
const resolvedUrl = await buildOriginWsUrl({
|
const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", {
|
||||||
origin,
|
origin,
|
||||||
localPort: 30002,
|
|
||||||
localPath: "",
|
|
||||||
remotePath: "/ssh/websocket/",
|
|
||||||
});
|
});
|
||||||
if (!resolvedUrl) {
|
if (!resolvedUrl) {
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
@@ -1568,7 +1555,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (origin === "local") {
|
if (isElectron() && origin === "local") {
|
||||||
try {
|
try {
|
||||||
outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig);
|
outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1586,8 +1573,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
baseWsUrl = resolvedUrl.url;
|
baseWsUrl = resolvedUrl.url;
|
||||||
wsProtocols = resolvedUrl.protocols;
|
wsProtocols = resolvedUrl.protocols;
|
||||||
} else {
|
|
||||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* How a plugin frontend reaches its own backend.
|
||||||
|
*
|
||||||
|
* Every plugin serves HTTP under /plugin-api/<id>/ and WebSockets under
|
||||||
|
* /plugin-ws/<id>/<path> on the main backend. No plugin has a port of its own,
|
||||||
|
* so these two helpers are all a plugin frontend needs, in every deployment:
|
||||||
|
*
|
||||||
|
* - web, same origin, optionally under a base path;
|
||||||
|
* - Docker behind nginx, which proxies both prefixes;
|
||||||
|
* - the Vite dev server, which proxies through /__termix_api/30001;
|
||||||
|
* - Electron with its embedded local backend;
|
||||||
|
* - Electron pointed at a remote Termix server, for hosts whose connection
|
||||||
|
* origin resolves there.
|
||||||
|
*
|
||||||
|
* A7 re-exports both through @termix/plugin-sdk/frontend. They live here for
|
||||||
|
* now because plugin frontends are still bundled with the shell.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosInstance } from "axios";
|
||||||
|
import { authApi } from "@/main-axios";
|
||||||
|
import { getBasePath } from "@/lib/base-path";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
|
import {
|
||||||
|
buildOriginWsUrl,
|
||||||
|
type ConnectionOrigin,
|
||||||
|
type WebSocketConnectionTarget,
|
||||||
|
} from "@/lib/connection-origin";
|
||||||
|
|
||||||
|
/** The backend port. Everything plugin-facing rides the main server. */
|
||||||
|
const BACKEND_PORT = 30001;
|
||||||
|
|
||||||
|
export function pluginApiPath(pluginId: string, path = ""): string {
|
||||||
|
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
|
||||||
|
return `/plugin-api/${pluginId}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An axios client rooted at this plugin's /plugin-api mount point.
|
||||||
|
*
|
||||||
|
* Built on the shared authenticated instance, so a plugin inherits the same
|
||||||
|
* interceptors as core: auth headers, session expiry handling and error
|
||||||
|
* reporting all behave identically.
|
||||||
|
*/
|
||||||
|
export function createPluginApi(pluginId: string): AxiosInstance {
|
||||||
|
const prefix = pluginApiPath(pluginId);
|
||||||
|
|
||||||
|
// A thin proxy rather than a new axios instance: the shared one resolves its
|
||||||
|
// baseURL per deployment already, and duplicating that here is how the two
|
||||||
|
// drift apart.
|
||||||
|
return new Proxy(authApi, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
const value = Reflect.get(target, property, receiver);
|
||||||
|
if (typeof value !== "function") return value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
property === "get" ||
|
||||||
|
property === "delete" ||
|
||||||
|
property === "head" ||
|
||||||
|
property === "options" ||
|
||||||
|
property === "post" ||
|
||||||
|
property === "put" ||
|
||||||
|
property === "patch"
|
||||||
|
) {
|
||||||
|
return (url: string, ...rest: unknown[]) =>
|
||||||
|
(value as (...args: unknown[]) => unknown).call(
|
||||||
|
target,
|
||||||
|
`${prefix}${url.startsWith("/") ? url : `/${url}`}`,
|
||||||
|
...rest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.bind(target);
|
||||||
|
},
|
||||||
|
}) as AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
|
||||||
|
* carry the JWT.
|
||||||
|
*
|
||||||
|
* In Electron the right backend depends on where the host actually lives, so
|
||||||
|
* pass the resolved origin; without one it targets the embedded local backend,
|
||||||
|
* which is what a plugin with no per-host notion of origin wants.
|
||||||
|
*/
|
||||||
|
export async function pluginWsUrl(
|
||||||
|
pluginId: string,
|
||||||
|
path: string,
|
||||||
|
options: { origin?: ConnectionOrigin } = {},
|
||||||
|
): Promise<WebSocketConnectionTarget | null> {
|
||||||
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
const route = `/plugin-ws/${pluginId}${suffix}`;
|
||||||
|
|
||||||
|
if (isElectron()) {
|
||||||
|
return buildOriginWsUrl({
|
||||||
|
origin: options.origin ?? "local",
|
||||||
|
localPort: BACKEND_PORT,
|
||||||
|
localPath: route,
|
||||||
|
remotePath: route,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("jwt");
|
||||||
|
const protocols = websocketAuthProtocols(token);
|
||||||
|
|
||||||
|
// Dev without a configured API host goes through Vite's proxy, which is
|
||||||
|
// reached on the page's own origin rather than the backend port.
|
||||||
|
const devProxy =
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
!import.meta.env.VITE_API_HOST &&
|
||||||
|
(window.location.port === "3000" || window.location.port === "5173");
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
|
||||||
|
if (devProxy) {
|
||||||
|
return {
|
||||||
|
url: `${wsProtocol}://${window.location.host}/__termix_api/${BACKEND_PORT}${route}`,
|
||||||
|
protocols,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${wsProtocol}://${window.location.host}${getBasePath()}${route}`,
|
||||||
|
protocols,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import { getRemoteStatsApi } from "@/main-axios";
|
import { getRemoteCoreApi } from "@/main-axios";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import type { SSHHost } from "@/types/index";
|
import type { SSHHost } from "@/types/index";
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
|
|||||||
const config = (await window.electronAPI?.invoke?.(
|
const config = (await window.electronAPI?.invoke?.(
|
||||||
"get-remote-sync-config",
|
"get-remote-sync-config",
|
||||||
)) as { serverUrl?: string } | null;
|
)) as { serverUrl?: string } | null;
|
||||||
return config?.serverUrl ? getRemoteStatsApi() : null;
|
return config?.serverUrl ? getRemoteCoreApi() : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-7
@@ -833,6 +833,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance {
|
|||||||
let remoteFileManagerApi: AxiosInstance | null = null;
|
let remoteFileManagerApi: AxiosInstance | null = null;
|
||||||
let remoteTunnelApi: AxiosInstance | null = null;
|
let remoteTunnelApi: AxiosInstance | null = null;
|
||||||
let remoteStatsApi: AxiosInstance | null = null;
|
let remoteStatsApi: AxiosInstance | null = null;
|
||||||
|
let remoteCoreApi: AxiosInstance | null = null;
|
||||||
let remoteGuacamoleApi: AxiosInstance | null = null;
|
let remoteGuacamoleApi: AxiosInstance | null = null;
|
||||||
|
|
||||||
export function getRemoteFileManagerApi(): AxiosInstance {
|
export function getRemoteFileManagerApi(): AxiosInstance {
|
||||||
@@ -851,11 +852,25 @@ export function getRemoteTunnelApi(): AxiosInstance {
|
|||||||
|
|
||||||
export function getRemoteStatsApi(): AxiosInstance {
|
export function getRemoteStatsApi(): AxiosInstance {
|
||||||
if (!remoteStatsApi) {
|
if (!remoteStatsApi) {
|
||||||
remoteStatsApi = createRemoteOriginApiInstance("");
|
remoteStatsApi = createRemoteOriginApiInstance("/plugin-api/host-metrics");
|
||||||
}
|
}
|
||||||
return remoteStatsApi;
|
return remoteStatsApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote server's core routes, unprefixed.
|
||||||
|
*
|
||||||
|
* Separate from getRemoteStatsApi because that one is rooted at the
|
||||||
|
* host-metrics plugin's mount point, and callers reaching core routes such as
|
||||||
|
* /sync/hosts must not inherit it.
|
||||||
|
*/
|
||||||
|
export function getRemoteCoreApi(): AxiosInstance {
|
||||||
|
if (!remoteCoreApi) {
|
||||||
|
remoteCoreApi = createRemoteOriginApiInstance("");
|
||||||
|
}
|
||||||
|
return remoteCoreApi;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRemoteGuacamoleApi(): AxiosInstance {
|
export function getRemoteGuacamoleApi(): AxiosInstance {
|
||||||
if (!remoteGuacamoleApi) {
|
if (!remoteGuacamoleApi) {
|
||||||
remoteGuacamoleApi = createRemoteOriginApiInstance("");
|
remoteGuacamoleApi = createRemoteOriginApiInstance("");
|
||||||
@@ -917,8 +932,13 @@ function initializeApiInstances() {
|
|||||||
"FILE_MANAGER",
|
"FILE_MANAGER",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Server Statistics API (port 30005)
|
// Host Metrics API - the host-metrics plugin, mounted on the main backend.
|
||||||
statsApi = createApiInstance(getApiUrl("", 30005), "STATS");
|
// Every existing /status, /metrics/*, /host-metrics/* and /proxmox-stats/*
|
||||||
|
// path still resolves, because the base carries the plugin mount point.
|
||||||
|
statsApi = createApiInstance(
|
||||||
|
getApiUrl("/plugin-api/host-metrics", 30001),
|
||||||
|
"STATS",
|
||||||
|
);
|
||||||
|
|
||||||
// Authentication API (port 30001)
|
// Authentication API (port 30001)
|
||||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||||
@@ -929,8 +949,11 @@ function initializeApiInstances() {
|
|||||||
// RBAC API (port 30001)
|
// RBAC API (port 30001)
|
||||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||||
|
|
||||||
// Docker Management API (port 30007)
|
// Docker Management API - the docker plugin, mounted on the main backend.
|
||||||
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
dockerApi = createApiInstance(
|
||||||
|
getApiUrl("/plugin-api/docker/docker", 30001),
|
||||||
|
"DOCKER",
|
||||||
|
);
|
||||||
|
|
||||||
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
||||||
tmuxMonitorApi = createApiInstance(
|
tmuxMonitorApi = createApiInstance(
|
||||||
@@ -953,7 +976,7 @@ export let tunnelApi: AxiosInstance;
|
|||||||
// File Manager Operations API (port 30004)
|
// File Manager Operations API (port 30004)
|
||||||
export let fileManagerApi: AxiosInstance;
|
export let fileManagerApi: AxiosInstance;
|
||||||
|
|
||||||
// Server Statistics API (port 30005)
|
// Host Metrics API (host-metrics plugin)
|
||||||
export let statsApi: AxiosInstance;
|
export let statsApi: AxiosInstance;
|
||||||
|
|
||||||
// Authentication API (port 30001)
|
// Authentication API (port 30001)
|
||||||
@@ -965,7 +988,7 @@ export let dashboardApi: AxiosInstance;
|
|||||||
// RBAC API (port 30001)
|
// RBAC API (port 30001)
|
||||||
export let rbacApi: AxiosInstance;
|
export let rbacApi: AxiosInstance;
|
||||||
|
|
||||||
// Docker Management API (port 30007)
|
// Docker Management API (docker plugin)
|
||||||
export let dockerApi: AxiosInstance;
|
export let dockerApi: AxiosInstance;
|
||||||
|
|
||||||
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
// Tmux Monitor API (port 30010) --- tmux-monitor ---
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe("SharedSessionView", () => {
|
|||||||
api.resolveShareLink.mockResolvedValue({
|
api.resolveShareLink.mockResolvedValue({
|
||||||
protocol: "rdp",
|
protocol: "rdp",
|
||||||
permissionLevel: "read-only",
|
permissionLevel: "read-only",
|
||||||
wsPath: "/guacamole/websocket/",
|
wsPath: "/plugin-ws/remote-desktop/display",
|
||||||
connectParams: { token: "guac-join-token" },
|
connectParams: { token: "guac-join-token" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -127,14 +127,15 @@ describe("resolveConnectionOrigin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The embedded backend authenticates a local WebSocket from `?token=`, because
|
* The embedded backend authenticates a local WebSocket from its subprotocol,
|
||||||
* the browser WebSocket API cannot set an Authorization header. Electron's main
|
* because the browser WebSocket API cannot set an Authorization header.
|
||||||
* process does inject a JWT cookie, but only on an exact origin match, and the
|
* Electron's main process does inject a JWT cookie, but only on an exact
|
||||||
* remembered cookie belongs to the API origin (`localhost:30001`) — so nothing
|
* origin match, so a socket has to carry the credential itself.
|
||||||
* is attached to a `127.0.0.1:30009` connection.
|
|
||||||
*
|
*
|
||||||
* The Docker console opted out of the query token and had no other credential
|
* This caught a real bug: the Docker console opted out of the token and had no
|
||||||
* left, so its handshake was closed with 1008 while logs and stats kept working.
|
* other credential left, so its handshake was closed with 1008 while logs and
|
||||||
|
* stats kept working. The fixtures below use the plugin socket routes these
|
||||||
|
* channels actually use now; every plugin shares the backend port.
|
||||||
*/
|
*/
|
||||||
describe("buildOriginWsUrl", () => {
|
describe("buildOriginWsUrl", () => {
|
||||||
const store: Record<string, string> = {};
|
const store: Record<string, string> = {};
|
||||||
@@ -154,13 +155,13 @@ describe("buildOriginWsUrl", () => {
|
|||||||
// Every interactive channel on the embedded backend relies on this.
|
// Every interactive channel on the embedded backend relies on this.
|
||||||
const target = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30009,
|
localPort: 30001,
|
||||||
localPath: "/docker/console/",
|
localPath: "/plugin-ws/docker/console",
|
||||||
remotePath: "/docker/console/",
|
remotePath: "/plugin-ws/docker/console",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(target).toEqual({
|
expect(target).toEqual({
|
||||||
url: "ws://127.0.0.1:30009/docker/console/",
|
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
|
||||||
protocols: ["termix.jwt.local-jwt"],
|
protocols: ["termix.jwt.local-jwt"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,14 +169,14 @@ describe("buildOriginWsUrl", () => {
|
|||||||
it("omits it only when a caller asks", async () => {
|
it("omits it only when a caller asks", async () => {
|
||||||
const target = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30009,
|
localPort: 30001,
|
||||||
localPath: "/docker/console/",
|
localPath: "/plugin-ws/docker/console",
|
||||||
remotePath: "/docker/console/",
|
remotePath: "/plugin-ws/docker/console",
|
||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(target).toEqual({
|
expect(target).toEqual({
|
||||||
url: "ws://127.0.0.1:30009/docker/console/",
|
url: "ws://127.0.0.1:30001/plugin-ws/docker/console",
|
||||||
protocols: [],
|
protocols: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -193,14 +194,14 @@ describe("buildOriginWsUrl", () => {
|
|||||||
|
|
||||||
const target = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "remote",
|
origin: "remote",
|
||||||
localPort: 30008,
|
localPort: 30001,
|
||||||
localPath: "/guacamole/websocket/",
|
localPath: "/plugin-ws/remote-desktop/display",
|
||||||
remotePath: "/guacamole/websocket/",
|
remotePath: "/plugin-ws/remote-desktop/display",
|
||||||
includeJwt: false,
|
includeJwt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(target).toEqual({
|
expect(target).toEqual({
|
||||||
url: "wss://termix.example/guacamole/websocket/",
|
url: "wss://termix.example/plugin-ws/remote-desktop/display",
|
||||||
protocols: [],
|
protocols: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -210,13 +211,13 @@ describe("buildOriginWsUrl", () => {
|
|||||||
|
|
||||||
const target = await buildOriginWsUrl({
|
const target = await buildOriginWsUrl({
|
||||||
origin: "local",
|
origin: "local",
|
||||||
localPort: 30002,
|
localPort: 30001,
|
||||||
localPath: "",
|
localPath: "/plugin-ws/ssh-terminal/terminal",
|
||||||
remotePath: "/ssh/websocket/",
|
remotePath: "/plugin-ws/ssh-terminal/terminal",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(target).toEqual({
|
expect(target).toEqual({
|
||||||
url: "ws://127.0.0.1:30002",
|
url: "ws://127.0.0.1:30001/plugin-ws/ssh-terminal/terminal",
|
||||||
protocols: [],
|
protocols: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user