diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 1e7344e33..6b4254c36 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -218,30 +218,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -282,21 +258,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -324,18 +285,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -372,16 +321,53 @@ http { 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// 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_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// 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 Connection "upgrade"; 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_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_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(/.*)?$ { @@ -393,15 +379,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -549,74 +526,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -741,15 +650,6 @@ http { 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 { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -759,81 +659,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; @@ -861,31 +686,6 @@ http { 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 --- location ~ ^/tmux_monitor(/.*)?$ { proxy_pass http://127.0.0.1:30010; @@ -901,19 +701,6 @@ http { } # --- 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/ { proxy_pass http://127.0.0.1:30011/; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index ba75bdcc9..4fcadf574 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -198,30 +198,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -262,21 +238,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -304,18 +265,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -352,16 +301,53 @@ http { 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// 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_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// 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 Connection "upgrade"; 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_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_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(/.*)?$ { @@ -373,15 +359,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -527,74 +504,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -717,15 +626,6 @@ http { 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 { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -735,81 +635,6 @@ http { 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(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; @@ -837,31 +662,6 @@ http { 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 --- location ~ ^/tmux_monitor(/.*)?$ { proxy_pass http://127.0.0.1:30010; @@ -877,19 +677,6 @@ http { } # --- 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/ { proxy_pass http://127.0.0.1:30011/; proxy_http_version 1.1; diff --git a/electron/main.cjs b/electron/main.cjs index 4af3aec2d..dcb69b099 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1024,8 +1024,8 @@ function getBackendPidFilePath() { // Manager) rather than through the normal quit flow, will-quit never fires // and stopBackendServer() never runs -- the forked backend child is a // genuinely separate OS process on Windows/mac/Linux, so it keeps running -// and holding every port the backend binds (30001, 30003-30008, 30010, -// 30012...). Every subsequent launch's own backend then fails outright +// and holding every port the backend binds (30001, 30003, 30004, 30006, +// 30010-30012). Every subsequent launch's own backend then fails outright // with EADDRINUSE and the app is stuck until something manually kills the // orphan. Reap any such leftover process, identified by PID file, before // spawning a new one. diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 9981713df..e9e4bcc6c 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -126,13 +126,51 @@ frozen array for a server that predates the endpoint. ### 6. HTTP and WebSockets Plugin routes live under `/plugin-api//` and sockets under -`/plugin-ws//`, served by the main backend with core auth. No plugin gets -its own port or nginx block. +`/plugin-ws//`, served by the main backend with core auth. No plugin +gets its own port or nginx block. -**A4** delivers this. In A1 `/plugin-api` is mounted and auth-gated but has no -routes registered, so it returns 404. The bundled plugins still keep their -original URLs through the dispatchers in `src/backend/database/routes/*-dispatch.ts`, -and four still own ports of their own. +**A4 delivered this.** A plugin calls `ctx.http.router(options)` and gets an +Express Router core mounts at `/plugin-api//`, or `ctx.ws.route(path, +handler)` for a socket. Core runs the same middleware in front of every plugin: + +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 @@ -407,13 +445,14 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.currentActor()` | none | **A1** | | `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | | `ctx.sync.registerEntity` | none | **A3** | -| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 | -| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 | -| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 | +| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | +| `ctx.rbac.require` | own permissions only | **A4** | +| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B | +| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B | | `ctx.settings.*` | `settings:read-core` | A6 | | `ctx.notify.*` | `notify:send` | A6 | | `ctx.auth.*` | `auth:provide` | A8 | -| `ctx.fetch` | `network:outbound` | A4 | +| `ctx.fetch` | `network:outbound` | B | ### The actor @@ -581,7 +620,7 @@ What the lint fence enforces today, in `eslint.config.mjs`: | Core importing a plugin backend | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | 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 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 an `optionalDependency` (added in A2; it previously declared nothing, and the loader had no reason to order the two). -- Eight plugins keep their original core URLs through the dispatchers in - `src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4 - moves them. -- Four plugins own ports directly (ssh-terminal 30002, host-metrics 30005, - docker 30007 and 30009, remote-desktop 30008). A4 moves them onto the main - server. +- Core feature servers that are not plugins yet still own ports: tunnel 30003, + file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage + 30012. Each keeps its nginx block until its own Phase B step. No plugin owns + a port any more (A4). - `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in `BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both. diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 461826025..c8f435680 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -150,6 +150,130 @@ export interface PluginSecrets { ) => Promise; } +/** + * 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; + [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//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// 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//. Call it once in + * activate and register routes on the result; it is unmounted automatically + * on deactivate. + */ + router: (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; + +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// 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 * registered through ctx is tracked automatically; this is for resources a @@ -170,6 +294,9 @@ export interface PluginContext { readonly registry: PluginRegistry; readonly services: PluginServices; readonly secrets: PluginSecrets; + readonly http: PluginHttp; + readonly ws: PluginWebSockets; + readonly rbac: PluginRbac; readonly disposables: PluginDisposables; /** diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 96ee84e6e..3dc2e3730 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -17,6 +17,7 @@ import type { import type { TermixApp } from "./frontend.js"; import type { PluginManifest } from "./manifest.js"; import type { PluginTableDefinition } from "./db.js"; +import type { PluginMiddleware, PluginRouterOptions } from "./backend.js"; import type { SyncEntityRegistration } from "./backend.js"; export interface FakeContextOptions { @@ -38,6 +39,10 @@ export interface FakePluginContext { tables: PluginTableDefinition[]; /** Sync entities registered through ctx.sync.registerEntity, in order. */ 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; } function noopLogger(): PluginLogger { @@ -63,6 +68,8 @@ export function createFakeContext( const kv = new Map(); const tables: PluginTableDefinition[] = []; const syncEntities: SyncEntityRegistration[] = []; + const wsRoutes: Array<{ path: string; raw: boolean }> = []; + const httpRouters: Array = []; const listeners = new Map void>>(); let actor = options.actor; @@ -153,6 +160,30 @@ export function createFakeContext( 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, asUser: async (userId, fn) => { @@ -168,7 +199,16 @@ export function createFakeContext( currentActor: () => actor, }; - return { ctx, disposals, emitted, kv, tables, syncEntities }; + return { + ctx, + disposals, + emitted, + kv, + tables, + syncEntities, + wsRoutes, + httpRouters, + }; } export interface MockContextOptions { @@ -285,6 +325,24 @@ export function createMockCtx( }, 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 }; diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json index 665938960..157f4b237 100644 --- a/plugins/ai/manifest.json +++ b/plugins/ai/manifest.json @@ -23,6 +23,7 @@ "secrets:own", "network:outbound", "audit:read", + "network:serve", "ui:surface" ], "dependencies": { diff --git a/plugins/ai/src/backend/index.ts b/plugins/ai/src/backend/index.ts index bdf5d7efe..05cb047e0 100644 --- a/plugins/ai/src/backend/index.ts +++ b/plugins/ai/src/backend/index.ts @@ -3,7 +3,7 @@ import { startAiService, stopAiService } from "./routes.js"; import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js"; export async function activate(ctx: PluginContext) { - startAiService(); + startAiService(ctx.http.router()); // 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 @@ -24,7 +24,7 @@ export async function activate(ctx: PluginContext) { 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() { diff --git a/plugins/ai/src/backend/routes.ts b/plugins/ai/src/backend/routes.ts index b88a772f3..95194fa3c 100644 --- a/plugins/ai/src/backend/routes.ts +++ b/plugins/ai/src/backend/routes.ts @@ -1,9 +1,5 @@ import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; -import express from "express"; -import { - registerAiRouter, - unregisterAiRouter, -} from "../../../../src/backend/database/routes/ai-dispatch.js"; +import express, { type Router } from "express"; import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import { PermissionManager } from "../../../../src/backend/utils/permission-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. */ -export function startAiService(): void { - registerAiRouter(router); +/** Called from activate(). Mounts this router at /plugin-api/ai. */ +export function startAiService(mountOn: Router): void { + mountOn.use(router); } /** Called from deactivate(). /ai/* falls back to 404 until reactivated. */ export function stopAiService(): void { - unregisterAiRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/automations/manifest.json b/plugins/automations/manifest.json index e376a1d7a..d5605e6c0 100644 --- a/plugins/automations/manifest.json +++ b/plugins/automations/manifest.json @@ -23,6 +23,7 @@ "events:core", "notify:send", "network:outbound", + "network:serve", "ui:surface" ], "locales": "locales", diff --git a/plugins/automations/src/backend/index.ts b/plugins/automations/src/backend/index.ts index d650c68fe..357ae2219 100644 --- a/plugins/automations/src/backend/index.ts +++ b/plugins/automations/src/backend/index.ts @@ -6,10 +6,14 @@ import { } from "./scheduler.js"; 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(); ctx.log.info( - "Automations router registered at /automations; scheduler started", + "Automations routes mounted at /plugin-api/automations; scheduler started", ); } diff --git a/plugins/automations/src/backend/routes.ts b/plugins/automations/src/backend/routes.ts index 8fb43277b..9c74d6897 100644 --- a/plugins/automations/src/backend/routes.ts +++ b/plugins/automations/src/backend/routes.ts @@ -1,5 +1,5 @@ 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 { AutomationDefinition, @@ -19,10 +19,6 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab import type { AutomationRow } from "../../../../src/backend/database/repositories/automation-repository.js"; import { AutomationEngine } from "./engine.js"; import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js"; -import { - registerAutomationsRouter, - unregisterAutomationsRouter, -} from "../../../../src/backend/database/routes/automation-dispatch.js"; 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 * dispatcher. */ -export function startAutomationsService(): void { - registerAutomationsRouter(router); +export function startAutomationsService(mountOn: Router): void { + mountOn.use(router); } /** Called from deactivate(). /automations/* falls back to 404 until * reactivated. */ export function stopAutomationsService(): void { - unregisterAutomationsRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/automations/src/frontend/automations-api.ts b/plugins/automations/src/frontend/automations-api.ts index ca3c6bc53..8fc6730b3 100644 --- a/plugins/automations/src/frontend/automations-api.ts +++ b/plugins/automations/src/frontend/automations-api.ts @@ -75,7 +75,7 @@ export interface AutomationInput { export async function listAutomations(): Promise { try { - return (await authApi.get("/automations")).data; + return (await authApi.get("/plugin-api/automations")).data; } catch (error) { throw handleApiError(error, "fetch automations"); } @@ -85,7 +85,7 @@ export async function createAutomation( input: AutomationInput, ): Promise { try { - return (await authApi.post("/automations", input)).data; + return (await authApi.post("/plugin-api/automations", input)).data; } catch (error) { throw handleApiError(error, "create automation"); } @@ -96,7 +96,7 @@ export async function updateAutomation( input: Partial, ): Promise { try { - return (await authApi.put(`/automations/${id}`, input)).data; + return (await authApi.put(`/plugin-api/automations/${id}`, input)).data; } catch (error) { throw handleApiError(error, "update automation"); } @@ -104,7 +104,7 @@ export async function updateAutomation( export async function deleteAutomation(id: number): Promise { try { - await authApi.delete(`/automations/${id}`); + await authApi.delete(`/plugin-api/automations/${id}`); } catch (error) { throw handleApiError(error, "delete automation"); } @@ -115,7 +115,8 @@ export async function runAutomation( options: { dryRun?: boolean } = {}, ): Promise { try { - return (await authApi.post(`/automations/${id}/run`, options)).data; + return (await authApi.post(`/plugin-api/automations/${id}/run`, options)) + .data; } catch (error) { throw handleApiError(error, "run automation"); } @@ -125,8 +126,11 @@ export async function listAutomationRuns( options: { automationId?: number; limit?: number; offset?: number } = {}, ): Promise { try { - return (await authApi.get("/automations/runs/history", { params: options })) - .data; + return ( + await authApi.get("/plugin-api/automations/runs/history", { + params: options, + }) + ).data; } catch (error) { throw handleApiError(error, "fetch automation runs"); } @@ -136,7 +140,8 @@ export async function listAutomationRunSteps( runId: number, ): Promise { try { - return (await authApi.get(`/automations/runs/${runId}/steps`)).data; + return (await authApi.get(`/plugin-api/automations/runs/${runId}/steps`)) + .data; } catch (error) { throw handleApiError(error, "fetch run steps"); } diff --git a/plugins/docker/src/backend/console.ts b/plugins/docker/src/backend/console.ts index f3458c762..a7f7b7eb0 100644 --- a/plugins/docker/src/backend/console.ts +++ b/plugins/docker/src/backend/console.ts @@ -45,14 +45,14 @@ interface SSHSession { const activeSessions = new Map(); -const CONSOLE_PORT = 30009; - /** * 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 * 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; @@ -60,40 +60,46 @@ function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) { 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 { if (wss) return Promise.resolve(); - return new Promise((resolve, reject) => { - const server = new WebSocketServer({ - host: "127.0.0.1", - port: CONSOLE_PORT, - maxPayload: MAX_WS_MESSAGE_BYTES, + const server = new WebSocketServer({ + noServer: true, + 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) => { - server.off("listening", onListening); - wss = null; - reject(error); - }; + wss = server; + return Promise.resolve(); +} - const onListening = () => { - server.off("error", onStartupError); - // Past startup an error is logged rather than fatal: one bad socket - // must not take the server down. - server.on("error", (error) => { - sshLogger.error("Docker console WebSocket server error", error, { - operation: "wss_error", - }); - }); - resolve(); - }; - - server.once("error", onStartupError); - server.once("listening", onListening); - server.on("connection", handleConnection); - - wss = server; +/** + * Hands one upgrade to the console server. + * + * Registered as a public ctx.ws route because onConsoleConnection does its own + * per-message authentication, which is where the container and host checks + * live too. + */ +export function handleConsoleUpgrade( + request: import("http").IncomingMessage, + socket: import("stream").Duplex, + head: Buffer, +): void { + const server = wss; + if (!server) { + socket.destroy(); + return; + } + 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 - * 30009. Called from the plugin's deactivate(). + * Closes every live console session and the WebSocket server. Called from the + * plugin's deactivate(). * * There is deliberately no process signal handler here. Core owns shutdown: * gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which @@ -894,5 +900,12 @@ export function closeConsoleServer(): Promise { if (!server) return Promise.resolve(); 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())); } diff --git a/plugins/docker/src/backend/index.ts b/plugins/docker/src/backend/index.ts index 301c041a9..c09856de8 100644 --- a/plugins/docker/src/backend/index.ts +++ b/plugins/docker/src/backend/index.ts @@ -1,9 +1,5 @@ -import { createServer, type Server } from "node:http"; -import express from "express"; -import cookieParser from "cookie-parser"; +import express, { type Router } from "express"; 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 { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { registerDockerContainerRoutes } from "./container-routes.js"; @@ -17,25 +13,21 @@ import { getRequestUserId, registerDockerSshRoutes, } 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 | null = null; 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(); - 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) => { res.setHeader("Cache-Control", "no-store"); next(); @@ -50,16 +42,9 @@ export async function activate(ctx: PluginContext) { dockerTimestampPattern: DOCKER_TIMESTAMP_RE, }); - const server = createServer(app); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(PORT, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - httpServer = server; + ctx.http.router({ bodyLimit: "100mb" }).use(app); + authManagerInstance = AuthManager.getInstance(); try { await authManagerInstance.initialize(); } 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(); - 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[0], + socket as Parameters[1], + head as Buffer, + ), + { public: true }, + ); + + ctx.log.info( + "Docker mounted at /plugin-api/docker and /plugin-ws/docker/console", + ); } export async function deactivate() { await closeConsoleServer(); - - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - } - authManagerInstance = null; } diff --git a/plugins/docker/src/frontend/components/ConsoleTerminal.tsx b/plugins/docker/src/frontend/components/ConsoleTerminal.tsx index 1d8577fb6..431affe97 100644 --- a/plugins/docker/src/frontend/components/ConsoleTerminal.tsx +++ b/plugins/docker/src/frontend/components/ConsoleTerminal.tsx @@ -9,11 +9,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { Button } from "@/components/button.tsx"; import { Select2 } from "@/components/select2"; import { Card, CardContent } from "@/components/card.tsx"; -import { getBasePath } from "@/lib/base-path"; -import { - resolveConnectionOrigin, - buildOriginWsUrl, -} from "@/lib/connection-origin.ts"; +import { resolveConnectionOrigin } from "@/lib/connection-origin.ts"; +import { pluginWsUrl } from "@/lib/plugin-transport"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { toast } from "sonner"; import type { SSHHost } from "@/types"; @@ -299,40 +296,21 @@ function ConsoleTerminalInner({ const isElectronApp = isElectron(); - const isDev = - !isElectronApp && - process.env.NODE_ENV === "development" && - (window.location.port === "3000" || - window.location.port === "5173" || - window.location.port === ""); + const origin = isElectronApp + ? await resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: hostConfig.connectionOrigin, + }) + : "local"; - let baseWsUrl: string; - let wsProtocols: string[] = []; - if (isDev) { - baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`; - } else if (isElectronApp) { - 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 resolvedUrl = await pluginWsUrl("docker", "/console", { origin }); + if (!resolvedUrl) { + setIsConnecting(false); + toast.error(t("errors.remoteServerRequired")); + return; } - const ws = new WebSocket(baseWsUrl, wsProtocols); + const ws = new WebSocket(resolvedUrl.url, resolvedUrl.protocols); ws.onopen = () => { const cols = terminal.cols || 80; diff --git a/plugins/fleets/manifest.json b/plugins/fleets/manifest.json index d8f942128..55c4005f7 100644 --- a/plugins/fleets/manifest.json +++ b/plugins/fleets/manifest.json @@ -19,6 +19,7 @@ "credentials:use", "ssh:connect", "db:own", + "network:serve", "ui:surface" ], "locales": "locales", diff --git a/plugins/fleets/src/backend/index.ts b/plugins/fleets/src/backend/index.ts index 60877b918..9df692456 100644 --- a/plugins/fleets/src/backend/index.ts +++ b/plugins/fleets/src/backend/index.ts @@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startFleetsService, stopFleetsService } from "./routes.js"; export async function activate(ctx: PluginContext) { - startFleetsService(); - ctx.log.info("Fleets router registered at /fleets"); + startFleetsService(ctx.http.router()); + ctx.log.info("Fleets routes mounted at /plugin-api/fleets"); } export async function deactivate() { diff --git a/plugins/fleets/src/backend/routes.ts b/plugins/fleets/src/backend/routes.ts index c9b58bd04..93d312fda 100644 --- a/plugins/fleets/src/backend/routes.ts +++ b/plugins/fleets/src/backend/routes.ts @@ -1,6 +1,6 @@ import { getErrorMessage } from "../../../../src/backend/utils/error-message.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 JSZip from "jszip"; import type { Client, SFTPWrapper } from "ssh2"; @@ -40,10 +40,6 @@ import { import { buildPackageActionCommand } from "../../../../src/backend/hosts/metrics-shared/package-commands.js"; import { isValidPackageName } from "../../../../src/backend/hosts/metrics-shared/validation.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(); @@ -1505,11 +1501,11 @@ export function buildRemoveCommand( /** Called from activate(). Mounts this router at /fleets via the shared * dispatcher. */ -export function startFleetsService(): void { - registerFleetsRouter(router); +export function startFleetsService(mountOn: Router): void { + mountOn.use(router); } /** Called from deactivate(). /fleets/* falls back to 404 until reactivated. */ export function stopFleetsService(): void { - unregisterFleetsRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/host-metrics/src/backend/index.ts b/plugins/host-metrics/src/backend/index.ts index ebf353714..7c3dff92d 100644 --- a/plugins/host-metrics/src/backend/index.ts +++ b/plugins/host-metrics/src/backend/index.ts @@ -1,20 +1,31 @@ -import type { Server } from "node:http"; import type { PluginContext } from "@termix/plugin-sdk/backend"; -import { PORT, shutdown, startHostMetricsService } from "./routes.js"; - -let httpServer: Server | null = null; +import { + onHostDeleted, + onHostLogin, + onHostUpdated, + shutdown, + startHostMetricsService, +} from "./routes.js"; export async function activate(ctx: PluginContext) { - httpServer = startHostMetricsService(); - ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`); + await startHostMetricsService(ctx.http.router()); + + // 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[0]); + }); + ctx.events.on("host.deleted", (payload) => { + onHostDeleted(payload as Parameters[0]); + }); + ctx.events.on("host.login", (payload) => { + onHostLogin(payload as Parameters[0]); + }); + + ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics"); } export async function deactivate() { shutdown(); - - if (httpServer) { - const server = httpServer; - httpServer = null; - await new Promise((resolve) => server.close(() => resolve())); - } } diff --git a/plugins/host-metrics/src/backend/routes.ts b/plugins/host-metrics/src/backend/routes.ts index 5c0efa025..8eec792ef 100644 --- a/plugins/host-metrics/src/backend/routes.ts +++ b/plugins/host-metrics/src/backend/routes.ts @@ -1,10 +1,7 @@ import { getErrorMessage } from "../../../../src/backend/utils/error-message.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 { 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 { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js"; import { @@ -116,7 +113,6 @@ import { requestQueue, statusPollLimiter, } from "../../../../src/backend/hosts/metrics-shared/state.js"; -import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js"; const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); @@ -1075,52 +1071,18 @@ function validateHostId( 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(); 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) => { res.setHeader("Cache-Control", "no-store"); next(); }); -// Internal endpoint — only accepts calls from localhost. Registered before -// 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()); +// Core authenticates every /plugin-api request before it reaches this app. const requireAdmin = authManager.createAdminMiddleware(); async function fetchAllHosts( @@ -2163,124 +2125,68 @@ app.post("/refresh", async (req, res) => { }); /** - * @openapi - * /host-updated: - * post: - * summary: Start polling for updated host - * description: Starts polling for a specific host after it has been updated. - * 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. + * Reacts to a host being created or updated somewhere in core. + * + * Used to be POST /host-updated on this plugin's own port. An event now, so + * there is no service-to-service HTTP call and no port. The acting user comes + * with the event because re-reading a host needs their data key. */ -app.post("/host-updated", async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const { hostId } = req.body; +export async function onHostUpdated(payload: { + hostId: number; + userId: string; +}): Promise { + const { hostId, userId } = payload; + if (!hostId || !userId) return; - 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" }); - } + // No data key means that user is not unlocked on this process, so there is + // nothing to decrypt the host with. Polling resumes when they next log in. + if (DataCrypto.getUserDataKey(userId) === null) return; try { hostPollCache.invalidate(hostId); const host = await fetchHostById(hostId, userId); - if (host) { - connectionPool.clearKeyConnections(getPoolKey(host)); + if (!host) return; - await pollingManager.startPollingForHost(host); - res.json({ message: "Host polling started" }); - } else { - res.status(404).json({ error: "Host not found" }); - } + connectionPool.clearKeyConnections(getPoolKey(host)); + await pollingManager.startPollingForHost(host); } catch (error) { statsLogger.error("Failed to start polling for host", error, { operation: "host_updated", hostId, userId, }); - res.status(500).json({ error: "Failed to start polling" }); } -}); +} -/** - * @openapi - * /host-deleted: - * post: - * 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" }); - } +/** Reacts to a host being deleted. Was POST /host-deleted. */ +export function onHostDeleted(payload: { hostId: number }): void { + const { hostId } = payload; + if (!hostId) return; try { pollingManager.stopPollingForHost(hostId, true); - res.json({ message: "Host polling stopped" }); } catch (error) { statsLogger.error("Failed to stop polling for host", error, { operation: "host_deleted", 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 @@ -3147,41 +3053,30 @@ registerManagerRoutes(app, { }, }); -export const PORT = 30005; - let cleanupInterval: NodeJS.Timeout | undefined; /** - * Starts the Host Metrics HTTP service. Called from the plugin's activate() - * instead of running at module scope, so enabling/disabling the plugin - * actually starts and stops the listener. + * Mounts the Host Metrics routes on the router core provides. Called from the + * plugin's activate(); there is no port to bind any more. */ -export function startHostMetricsService(): ReturnType< - typeof listenOnServicePort -> { - 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", - }); - } +export async function startHostMetricsService(mountOn: Router): Promise { + mountOn.use(app); - cleanupInterval = setInterval( - () => { - authFailureTracker.cleanup(); - pollingBackoff.cleanup(); - }, - 10 * 60 * 1000, - ); + try { + await authManager.initialize(); + } catch (err) { + statsLogger.error("Failed to initialize AuthManager", err, { + operation: "auth_init_error", + }); + } + + cleanupInterval = setInterval( + () => { + authFailureTracker.cleanup(); + pollingBackoff.cleanup(); }, - }); + 10 * 60 * 1000, + ); } /** diff --git a/plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts b/plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts deleted file mode 100644 index b548777ea..000000000 --- a/plugins/host-metrics/tests/backend/login-alert-dispatch.test.ts +++ /dev/null @@ -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; -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(); - }, -); diff --git a/plugins/host-metrics/tests/backend/login-alert-route-order.test.ts b/plugins/host-metrics/tests/backend/login-alert-route-order.test.ts deleted file mode 100644 index 8725e8588..000000000 --- a/plugins/host-metrics/tests/backend/login-alert-route-order.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/plugins/host-metrics/tests/backend/login-alert.test.ts b/plugins/host-metrics/tests/backend/login-alert.test.ts new file mode 100644 index 000000000..7e1569aad --- /dev/null +++ b/plugins/host-metrics/tests/backend/login-alert.test.ts @@ -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 = { + 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[0]); + + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/plugins/network-topology/manifest.json b/plugins/network-topology/manifest.json index 7239a0e6d..681e08316 100644 --- a/plugins/network-topology/manifest.json +++ b/plugins/network-topology/manifest.json @@ -14,7 +14,7 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": ["db:own", "ui:surface"], + "capabilities": ["db:own", "network:serve", "ui:surface"], "contributes": { "tabs": [ { diff --git a/plugins/network-topology/src/backend/index.ts b/plugins/network-topology/src/backend/index.ts index 4d1fa907d..ea6757fe1 100644 --- a/plugins/network-topology/src/backend/index.ts +++ b/plugins/network-topology/src/backend/index.ts @@ -5,8 +5,10 @@ import { } from "./routes.js"; export async function activate(ctx: PluginContext) { - startNetworkTopologyService(); - ctx.log.info("Network Topology router registered at /network-topology"); + startNetworkTopologyService(ctx.http.router()); + ctx.log.info( + "Network Topology routes mounted at /plugin-api/network-topology", + ); } export async function deactivate() { diff --git a/plugins/network-topology/src/backend/routes.ts b/plugins/network-topology/src/backend/routes.ts index 4399ea75f..afdb60e5a 100644 --- a/plugins/network-topology/src/backend/routes.ts +++ b/plugins/network-topology/src/backend/routes.ts @@ -1,12 +1,8 @@ -import express from "express"; +import express, { type Router } from "express"; import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../../src/types/index.js"; import { databaseLogger } from "../../../../src/backend/utils/logger.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(); @@ -179,10 +175,10 @@ router.post( }, ); -export function startNetworkTopologyService(): void { - registerNetworkTopologyRouter(router); +export function startNetworkTopologyService(mountOn: Router): void { + mountOn.use(router); } export function stopNetworkTopologyService(): void { - unregisterNetworkTopologyRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/proxmox/manifest.json b/plugins/proxmox/manifest.json index 44db166cb..b067f4a0d 100644 --- a/plugins/proxmox/manifest.json +++ b/plugins/proxmox/manifest.json @@ -20,6 +20,7 @@ "credentials:use", "ssh:connect", "db:own", + "network:serve", "ui:surface" ], "contributes": { diff --git a/plugins/proxmox/src/backend/index.ts b/plugins/proxmox/src/backend/index.ts index 9b735d1c8..64bba2ea8 100644 --- a/plugins/proxmox/src/backend/index.ts +++ b/plugins/proxmox/src/backend/index.ts @@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startProxmoxService, stopProxmoxService } from "./routes.js"; export async function activate(ctx: PluginContext) { - startProxmoxService(); - ctx.log.info("Proxmox router registered at /proxmox"); + startProxmoxService(ctx.http.router()); + ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox"); } export async function deactivate() { diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index 56538e127..d0331e6d0 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -1,5 +1,5 @@ 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 { logger } from "../../../../src/backend/utils/logger.js"; import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js"; @@ -19,10 +19,6 @@ import { } from "./proxmox-jump-hosts.js"; import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.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 proxmoxLogger = logger; @@ -1139,8 +1135,8 @@ router.post( * 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 * process alive on their own). */ -export function startProxmoxService(): void { - registerProxmoxRouter(router); +export function startProxmoxService(mountOn: Router): void { + mountOn.use(router); proxmoxAutoSyncTimer = setInterval(runDueProxmoxAutoSyncs, 60 * 1000); proxmoxAutoSyncTimer.unref?.(); proxmoxAutoSyncStartupTimer = setTimeout(runDueProxmoxAutoSyncs, 30 * 1000); @@ -1150,7 +1146,7 @@ export function startProxmoxService(): void { /** Called from deactivate(). /proxmox/* falls back to 404 until reactivated, * and the auto-sync timers are cleared so a disabled plugin stops syncing. */ export function stopProxmoxService(): void { - unregisterProxmoxRouter(); + // Unmounted by the runtime when the plugin deactivates. if (proxmoxAutoSyncTimer) clearInterval(proxmoxAutoSyncTimer); if (proxmoxAutoSyncStartupTimer) clearTimeout(proxmoxAutoSyncStartupTimer); proxmoxAutoSyncTimer = undefined; diff --git a/plugins/remote-desktop/src/backend/guacamole-server.ts b/plugins/remote-desktop/src/backend/guacamole-server.ts index 956e6f5e6..5e759d238 100644 --- a/plugins/remote-desktop/src/backend/guacamole-server.ts +++ b/plugins/remote-desktop/src/backend/guacamole-server.ts @@ -24,7 +24,6 @@ function readGuacdOptions(): { host: string; port: number } { return resolveGuacdOptions(dbUrl); } -export const GUAC_WS_PORT = 30008; const DATA_DIR = process.env.DATA_DIR || "./db/data"; const GUACAMOLE_RECORDINGS_DIR = 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 = { - host: "127.0.0.1", - port: GUAC_WS_PORT, + noServer: true, }; const clientOptions = { @@ -253,13 +255,48 @@ function createGuacServer(): GuacamoleLite { let guacServer: GuacamoleLite | null = null; /** - * Starts the guacamole-lite WebSocket server. Called from this plugin's - * activate() -- see plugins/remote-desktop/backend/index.mjs. + * Builds the guacamole-lite server. Called from this plugin's activate(). + * Nothing binds a port: handleGuacamoleUpgrade below feeds it connections. */ export async function startGuacamoleService(): Promise { 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 * deactivate(). diff --git a/plugins/remote-desktop/src/backend/index.ts b/plugins/remote-desktop/src/backend/index.ts index d20d1f8a9..7a2dec6f7 100644 --- a/plugins/remote-desktop/src/backend/index.ts +++ b/plugins/remote-desktop/src/backend/index.ts @@ -1,33 +1,51 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { - GUAC_WS_PORT, getGuacSessionInfo, + handleGuacamoleUpgrade, restartGuacServer, startGuacamoleService, stopGuacamoleService, tokenService, } from "./guacamole-server.js"; -import { router } from "./routes.js"; -import { - registerGuacamoleBridge, - unregisterGuacamoleBridge, -} from "../../../../src/backend/database/routes/guacamole-dispatch.js"; +import { startRemoteDesktopService } from "./routes.js"; export async function activate(ctx: PluginContext) { await startGuacamoleService(); - registerGuacamoleBridge({ - router, + startRemoteDesktopService(ctx.http.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[0], + socket as Parameters[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, createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => tokenService.createJoinToken(guacamoleConnectionId, readOnly), 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() { - unregisterGuacamoleBridge(); await stopGuacamoleService(); } diff --git a/plugins/remote-desktop/src/backend/routes.ts b/plugins/remote-desktop/src/backend/routes.ts index 0629fcf02..db2871a53 100644 --- a/plugins/remote-desktop/src/backend/routes.ts +++ b/plugins/remote-desktop/src/backend/routes.ts @@ -1,10 +1,9 @@ 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 { withRecordingSettings } from "./recording-settings.js"; import { withDriveSettings } from "./drive-settings.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 { resolveRecipientSharedHostAuthentication, @@ -37,10 +36,10 @@ import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); -const authManager = AuthManager.getInstance(); 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) => { if (!req.userId) @@ -816,7 +815,7 @@ router.get("/status", async (req, res) => { status: isConnected ? "connected" : "disconnected", }, websocket: { - port: 30008, + path: "/plugin-ws/remote-desktop/display", status: "running", }, }); @@ -829,3 +828,7 @@ router.get("/status", async (req, res) => { }); export { router }; + +export function startRemoteDesktopService(mountOn: Router): void { + mountOn.use(router); +} diff --git a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx index b8f5db6eb..52ecf04b4 100644 --- a/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx +++ b/plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx @@ -285,11 +285,13 @@ export const GuacamoleDisplay = forwardRef< let wsBase: string | null; if (isElectron()) { + // includeJwt stays off: a display authenticates with the encrypted + // connection token in the query, not a session. const target = await buildOriginWsUrl({ origin, - localPort: 30008, - localPath: "/guacamole/websocket/", - remotePath: "/guacamole/websocket/", + localPort: 30001, + localPath: "/plugin-ws/remote-desktop/display", + remotePath: "/plugin-ws/remote-desktop/display", includeJwt: false, }); if (!target) { @@ -299,7 +301,6 @@ export const GuacamoleDisplay = forwardRef< wsBase = target.url; } else { wsBase = buildGuacamoleWebSocketBaseUrl({ - isDev, isElectronApp: false, isEmbeddedApp: false, basePath: getBasePath(), diff --git a/plugins/remote-desktop/src/frontend/guacamole-api.ts b/plugins/remote-desktop/src/frontend/guacamole-api.ts index 3e21f41c0..f17cfd84e 100644 --- a/plugins/remote-desktop/src/frontend/guacamole-api.ts +++ b/plugins/remote-desktop/src/frontend/guacamole-api.ts @@ -174,17 +174,20 @@ export async function getGuacamoleToken( try { const guacParams = toGuacamoleParams(request.guacamoleConfig); - const response = await guacamoleApi(origin).post("/guacamole/token", { - type: request.protocol, - hostname: request.hostname, - port: request.port, - username: request.username, - password: request.password, - domain: request.domain, - security: request.security, - "ignore-cert": request.ignoreCert, - ...guacParams, - }); + const response = await guacamoleApi(origin).post( + "/plugin-api/remote-desktop/token", + { + type: request.protocol, + hostname: request.hostname, + port: request.port, + username: request.username, + password: request.password, + domain: request.domain, + security: request.security, + "ignore-cert": request.ignoreCert, + ...guacParams, + }, + ); return response.data; } catch (error) { throw handleApiError(error, "get guacamole token"); @@ -214,7 +217,7 @@ export async function getGuacamoleTokenFromHost( } const targetHostId = remoteHostId ?? hostId; const response = await guacamoleApi(origin).post( - `/guacamole/connect-host/${targetHostId}`, + `/plugin-api/remote-desktop/connect-host/${targetHostId}`, { ...(protocol ? { protocol } : {}), ...(promptedCredentials?.username @@ -237,7 +240,9 @@ export async function getGuacamoleTokenFromHost( export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{ 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; } @@ -247,7 +252,7 @@ export async function getGuacamoleConnectionId( signal?: AbortSignal, ): Promise { const response = await guacamoleApi(origin).get( - `/guacamole/connection/${encodeURIComponent(connectId)}`, + `/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`, { signal }, ); return response.data.guacamoleConnectionId ?? null; diff --git a/plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts b/plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts index 717751c7e..7d1af5ae5 100644 --- a/plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts +++ b/plugins/remote-desktop/src/frontend/guacamole-websocket-url.ts @@ -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({ - isDev, isElectronApp, isEmbeddedApp, configuredServerUrl, basePath, location, }: { - isDev: boolean; + isDev?: boolean; isElectronApp: boolean; isEmbeddedApp: boolean; configuredServerUrl?: string; basePath: string; location: Pick; }) { - if (isDev) return "ws://localhost:30008"; - if (isElectronApp) { - if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008"; + const route = "/plugin-ws/remote-desktop/display"; + if (isElectronApp && !isEmbeddedApp && configuredServerUrl) { const wsProtocol = configuredServerUrl.startsWith("https://") ? "wss://" : "ws://"; const wsHost = configuredServerUrl .replace(/^https?:\/\//, "") .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"; - return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`; + return `${wsProtocol}://${location.host}${basePath}${route}`; } diff --git a/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts index a1535607c..73b125d60 100644 --- a/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-api.test.ts @@ -42,7 +42,9 @@ describe("guacamole API origin", () => { await getGuacdStatus("remote"); 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(remoteApiMock.get).not.toHaveBeenCalled(); expect(remoteApiMock.post).not.toHaveBeenCalled(); @@ -58,7 +60,9 @@ describe("guacamole API origin", () => { expect(status.guacd.status).toBe("connected"); 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(authApiMock.get).not.toHaveBeenCalled(); expect(authApiMock.post).not.toHaveBeenCalled(); @@ -81,7 +85,7 @@ describe("guacamole API origin", () => { ); expect(remoteApiMock.post).toHaveBeenCalledWith( - "/guacamole/connect-host/41", + "/plugin-api/remote-desktop/connect-host/41", { protocol: "rdp", promptedUsername: "admin", @@ -101,12 +105,15 @@ describe("guacamole API origin", () => { domain: "", }); - expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", { - protocol: "rdp", - promptedUsername: "local-admin", - promptedPassword: "secret", - promptedDomain: "", - }); + expect(authApiMock.post).toHaveBeenCalledWith( + "/plugin-api/remote-desktop/connect-host/9", + { + protocol: "rdp", + promptedUsername: "local-admin", + promptedPassword: "secret", + promptedDomain: "", + }, + ); }); // 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"); expect(token.token).toBe("local-token"); - expect(authApiMock.post).toHaveBeenCalledWith("/guacamole/connect-host/9", { - protocol: "rdp", - }); + expect(authApiMock.post).toHaveBeenCalledWith( + "/plugin-api/remote-desktop/connect-host/9", + { + protocol: "rdp", + }, + ); expect(remoteApiMock.post).not.toHaveBeenCalled(); expect(resolveRemoteHostIdMock).not.toHaveBeenCalled(); }); @@ -131,7 +141,9 @@ describe("guacamole API origin", () => { const status = await getGuacdStatus("local"); 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(); }); @@ -166,7 +178,7 @@ it("looks up the session on the backend that issued its token", async () => { const signal = new AbortController().signal; await getGuacamoleConnectionId("connect/id", "remote", signal); expect(remoteApiMock.get).toHaveBeenCalledWith( - "/guacamole/connection/connect%2Fid", + "/plugin-api/remote-desktop/connection/connect%2Fid", { signal }, ); expect(authApiMock.get).not.toHaveBeenCalled(); diff --git a/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts b/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts index c2b267fe6..81cb19fe3 100644 --- a/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts +++ b/plugins/remote-desktop/tests/frontend/guacamole-websocket-url.test.ts @@ -6,63 +6,55 @@ const httpsLocation = { host: "termix.example.com", } 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", () => { - it("uses the same origin guacamole websocket path in production web builds", () => { + it("uses the same origin in production web builds", () => { expect( buildGuacamoleWebSocketBaseUrl({ - isDev: false, isElectronApp: false, isEmbeddedApp: false, basePath: "", 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", () => { expect( buildGuacamoleWebSocketBaseUrl({ - isDev: false, isElectronApp: false, isEmbeddedApp: false, basePath: "/termix", 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( buildGuacamoleWebSocketBaseUrl({ - isDev: true, - isElectronApp: false, - isEmbeddedApp: false, - basePath: "/termix", - location: httpsLocation, - }), - ).toBe("ws://localhost:30008"); - - expect( - buildGuacamoleWebSocketBaseUrl({ - isDev: false, isElectronApp: true, isEmbeddedApp: true, basePath: "/termix", 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", () => { expect( buildGuacamoleWebSocketBaseUrl({ - isDev: false, isElectronApp: true, isEmbeddedApp: false, configuredServerUrl: "https://termix.example.com/termix/", basePath: "", location: httpsLocation, }), - ).toBe("wss://termix.example.com/termix/guacamole/websocket/"); + ).toBe(`wss://termix.example.com/termix${ROUTE}`); }); }); diff --git a/plugins/ssh-terminal/src/backend/index.ts b/plugins/ssh-terminal/src/backend/index.ts index 08f1d7bb6..a25d3af44 100644 --- a/plugins/ssh-terminal/src/backend/index.ts +++ b/plugins/ssh-terminal/src/backend/index.ts @@ -1,19 +1,30 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { - TERMINAL_WS_PORT, + handleTerminalUpgrade, sessionManager, - startTerminalServer, stopTerminalServer, } from "../../../../src/backend/hosts/terminal/index.js"; 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[0], + socket as Parameters[1], + head as Buffer, + ), + { public: true }, + ); // Published so collab and session-sharing can reach live sessions without // importing a plugin. Revoked automatically when this plugin is disabled. 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() { diff --git a/plugins/tailscale/manifest.json b/plugins/tailscale/manifest.json index 1c6c83be5..36e1908a0 100644 --- a/plugins/tailscale/manifest.json +++ b/plugins/tailscale/manifest.json @@ -14,7 +14,12 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": ["settings:read-core", "network:outbound", "ui:surface"], + "capabilities": [ + "settings:read-core", + "network:outbound", + "network:serve", + "ui:surface" + ], "contributes": { "permissionGroup": { "group": "tailscale", diff --git a/plugins/tailscale/src/backend/index.ts b/plugins/tailscale/src/backend/index.ts index 4f7c5424a..64cda93e5 100644 --- a/plugins/tailscale/src/backend/index.ts +++ b/plugins/tailscale/src/backend/index.ts @@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startTailscaleService, stopTailscaleService } from "./routes.js"; export async function activate(ctx: PluginContext) { - startTailscaleService(); - ctx.log.info("Tailscale router registered at /tailscale"); + startTailscaleService(ctx.http.router()); + ctx.log.info("Tailscale routes mounted at /plugin-api/tailscale"); } export async function deactivate() { diff --git a/plugins/tailscale/src/backend/routes.ts b/plugins/tailscale/src/backend/routes.ts index df45ac6bf..4803ca1b5 100644 --- a/plugins/tailscale/src/backend/routes.ts +++ b/plugins/tailscale/src/backend/routes.ts @@ -9,10 +9,6 @@ import { PermissionManager } from "../../../../src/backend/utils/permission-mana import { apiLogger } from "../../../../src/backend/utils/logger.js"; import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js"; import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js"; -import { - registerTailscaleRouter, - unregisterTailscaleRouter, -} from "../../../../src/backend/database/routes/tailscale-dispatch.js"; interface TailscaleDevice { id: string; @@ -152,12 +148,12 @@ router.get( /** Called from activate(). Mounts this router at /tailscale via the shared * dispatcher. */ -export function startTailscaleService(): void { - registerTailscaleRouter(router); +export function startTailscaleService(mountOn: Router): void { + mountOn.use(router); } /** Called from deactivate(). /tailscale/* falls back to 404 until * reactivated. */ export function stopTailscaleService(): void { - unregisterTailscaleRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/tailscale/src/frontend/tailscale-api.ts b/plugins/tailscale/src/frontend/tailscale-api.ts index 6221f479f..7d3befa61 100644 --- a/plugins/tailscale/src/frontend/tailscale-api.ts +++ b/plugins/tailscale/src/frontend/tailscale-api.ts @@ -47,7 +47,7 @@ export async function getTailscaleDevices(): Promise<{ error?: string; }> { try { - const response = await authApi.get("/tailscale/devices"); + const response = await authApi.get("/plugin-api/tailscale/devices"); return response.data; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/plugins/web-endpoint/manifest.json b/plugins/web-endpoint/manifest.json index 4335d1ca3..4d56bd259 100644 --- a/plugins/web-endpoint/manifest.json +++ b/plugins/web-endpoint/manifest.json @@ -18,6 +18,7 @@ "hosts:read", "credentials:use", "ssh:connect", + "network:serve", "ui:surface" ], "contributes": { diff --git a/plugins/web-endpoint/src/backend/index.ts b/plugins/web-endpoint/src/backend/index.ts index e0812ff9f..0e3a16d0d 100644 --- a/plugins/web-endpoint/src/backend/index.ts +++ b/plugins/web-endpoint/src/backend/index.ts @@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startWebEndpointService, stopWebEndpointService } from "./routes.js"; export async function activate(ctx: PluginContext) { - startWebEndpointService(); - ctx.log.info("Web Endpoint router registered at /ssh/tunnel/web-endpoint"); + startWebEndpointService(ctx.http.router()); + ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint"); } export async function deactivate() { diff --git a/plugins/web-endpoint/src/backend/routes.ts b/plugins/web-endpoint/src/backend/routes.ts index 02c206abd..2370b4763 100644 --- a/plugins/web-endpoint/src/backend/routes.ts +++ b/plugins/web-endpoint/src/backend/routes.ts @@ -1,4 +1,4 @@ -import express from "express"; +import express, { type Router } from "express"; import { Client } from "ssh2"; import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { tunnelLogger } from "../../../../src/backend/utils/logger.js"; @@ -12,10 +12,6 @@ import { } from "../../../../src/backend/hosts/tunnel/manager.js"; import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.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"; /** Matches the spec's ten minutes. */ @@ -302,7 +298,7 @@ export const router = express.Router(); let started = false; -export function startWebEndpointService(): void { +export function startWebEndpointService(mountOn: Router): void { if (!started) { // Built on first start rather than at module scope so importing this // 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); started = true; } - registerWebEndpointRouter(router); + mountOn.use(router); } export function stopWebEndpointService(): void { - unregisterWebEndpointRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/web-endpoint/tests/backend/routes.test.ts b/plugins/web-endpoint/tests/backend/routes.test.ts index 283f81f35..4a61b0707 100644 --- a/plugins/web-endpoint/tests/backend/routes.test.ts +++ b/plugins/web-endpoint/tests/backend/routes.test.ts @@ -305,33 +305,28 @@ describe("POST /tunnel/web-endpoint/open", () => { }); /** - * The client resolves "/tunnel/web-endpoint/open" against a baseURL that - * 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. + * The client path and the server path have to agree. * - * 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 - * point in src/backend/hosts/tunnel/index.ts and the route on the plugin's own - * router. Either half drifting breaks the URL, so both are asserted here. + * The shape changed with A4. The route used to be composed of a mount point in + * the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on + * this router. Now core mounts every plugin at /plugin-api/, 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", () => { - 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 dispatch = - await import("../../../../src/backend/hosts/tunnel/web-endpoint-dispatch.js"); - const registered: unknown[] = []; - vi.spyOn(dispatch, "registerWebEndpointRouter").mockImplementation( - (router) => { - registered.push(router); - }, - ); + const express = (await import("express")).default; + const mountOn = express.Router(); - routes.startWebEndpointService(); + routes.startWebEndpointService(mountOn); - expect(registered).toHaveLength(1); const paths = ( routes.router as unknown as { stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>; @@ -339,15 +334,17 @@ describe("route registration", () => { ).stack .filter((layer) => layer.route?.methods.post) .map((layer) => layer.route?.path); + 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( - new URL("../../../../src/backend/hosts/tunnel/index.ts", import.meta.url), + new URL("../../src/backend/index.ts", import.meta.url), "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()"); }); }); diff --git a/plugins/workspaces/manifest.json b/plugins/workspaces/manifest.json index e2a6b94d7..b46e79162 100644 --- a/plugins/workspaces/manifest.json +++ b/plugins/workspaces/manifest.json @@ -14,7 +14,7 @@ "termix": ">=2.9.0", "api": "1" }, - "capabilities": ["db:own", "ui:surface"], + "capabilities": ["db:own", "network:serve", "ui:surface"], "locales": "locales", "platforms": ["linux", "win32", "darwin"] } diff --git a/plugins/workspaces/src/backend/index.ts b/plugins/workspaces/src/backend/index.ts index ae61b0f64..9da37874b 100644 --- a/plugins/workspaces/src/backend/index.ts +++ b/plugins/workspaces/src/backend/index.ts @@ -2,8 +2,8 @@ import type { PluginContext } from "@termix/plugin-sdk/backend"; import { startWorkspacesService, stopWorkspacesService } from "./routes.js"; export async function activate(ctx: PluginContext) { - startWorkspacesService(); - ctx.log.info("Workspaces router registered at /workspaces"); + startWorkspacesService(ctx.http.router()); + ctx.log.info("Workspaces routes mounted at /plugin-api/workspaces"); } export async function deactivate() { diff --git a/plugins/workspaces/src/backend/routes.ts b/plugins/workspaces/src/backend/routes.ts index 15e431a2d..5360518c9 100644 --- a/plugins/workspaces/src/backend/routes.ts +++ b/plugins/workspaces/src/backend/routes.ts @@ -1,13 +1,9 @@ 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 { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { createCurrentWorkspaceRepository } from "../../../../src/backend/database/repositories/factory.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(); @@ -639,10 +635,10 @@ router.delete( }, ); -export function startWorkspacesService(): void { - registerWorkspacesRouter(router); +export function startWorkspacesService(mountOn: Router): void { + mountOn.use(router); } export function stopWorkspacesService(): void { - unregisterWorkspacesRouter(); + // Unmounted by the runtime when the plugin deactivates. } diff --git a/plugins/workspaces/src/frontend/workspaces-api.ts b/plugins/workspaces/src/frontend/workspaces-api.ts index 01f4c4a3e..490697c37 100644 --- a/plugins/workspaces/src/frontend/workspaces-api.ts +++ b/plugins/workspaces/src/frontend/workspaces-api.ts @@ -3,7 +3,7 @@ import type { Workspace, WorkspacePayload } from "@/types/ui-types"; export async function listWorkspaces(): Promise { try { - const response = await authApi.get("/workspaces"); + const response = await authApi.get("/plugin-api/workspaces"); return response.data; } catch (error) { throw handleApiError(error, "fetch workspaces"); @@ -17,7 +17,7 @@ export async function createWorkspace(data: { payload: WorkspacePayload; }): Promise { try { - const response = await authApi.post("/workspaces", data); + const response = await authApi.post("/plugin-api/workspaces", data); return response.data; } catch (error) { throw handleApiError(error, "create workspace"); @@ -29,7 +29,7 @@ export async function renameWorkspace( data: { name?: string; color?: string | null; icon?: string | null }, ): Promise { try { - const response = await authApi.patch(`/workspaces/${id}`, data); + const response = await authApi.patch(`/plugin-api/workspaces/${id}`, data); return response.data; } catch (error) { throw handleApiError(error, "update workspace"); @@ -41,7 +41,7 @@ export async function updateWorkspaceContent( payload: WorkspacePayload, ): Promise { try { - const response = await authApi.put(`/workspaces/${id}/content`, { + const response = await authApi.put(`/plugin-api/workspaces/${id}/content`, { payload, }); return response.data; @@ -54,7 +54,7 @@ export async function deleteWorkspace( id: number, ): Promise<{ success: boolean }> { try { - const response = await authApi.delete(`/workspaces/${id}`); + const response = await authApi.delete(`/plugin-api/workspaces/${id}`); return response.data; } catch (error) { throw handleApiError(error, "delete workspace"); @@ -66,9 +66,12 @@ export async function duplicateWorkspace( name: string, ): Promise { try { - const response = await authApi.post(`/workspaces/${id}/duplicate`, { - name, - }); + const response = await authApi.post( + `/plugin-api/workspaces/${id}/duplicate`, + { + name, + }, + ); return response.data; } catch (error) { throw handleApiError(error, "duplicate workspace"); @@ -77,7 +80,9 @@ export async function duplicateWorkspace( export async function setDefaultWorkspace(id: number): Promise { try { - const response = await authApi.post(`/workspaces/${id}/set-default`); + const response = await authApi.post( + `/plugin-api/workspaces/${id}/set-default`, + ); return response.data; } catch (error) { throw handleApiError(error, "set default workspace"); @@ -86,7 +91,9 @@ export async function setDefaultWorkspace(id: number): Promise { export async function unsetDefaultWorkspace(id: number): Promise { try { - const response = await authApi.post(`/workspaces/${id}/unset-default`); + const response = await authApi.post( + `/plugin-api/workspaces/${id}/unset-default`, + ); return response.data; } catch (error) { throw handleApiError(error, "unset default workspace"); @@ -95,7 +102,7 @@ export async function unsetDefaultWorkspace(id: number): Promise { export async function applyWorkspaceServer(id: number): Promise { try { - const response = await authApi.post(`/workspaces/${id}/apply`); + const response = await authApi.post(`/plugin-api/workspaces/${id}/apply`); return response.data; } catch (error) { throw handleApiError(error, "apply workspace"); @@ -104,7 +111,7 @@ export async function applyWorkspaceServer(id: number): Promise { export async function getLastSessionWorkspace(): Promise { try { - const response = await authApi.get("/workspaces/last-session"); + const response = await authApi.get("/plugin-api/workspaces/last-session"); return response.data; } catch (error) { throw handleApiError(error, "fetch last session workspace"); @@ -115,7 +122,7 @@ export async function saveLastSessionWorkspace( payload: WorkspacePayload, ): Promise { try { - const response = await authApi.put("/workspaces/last-session", { + const response = await authApi.put("/plugin-api/workspaces/last-session", { payload, }); return response.data; diff --git a/plugins/workspaces/tests/frontend/workspaces-api.test.ts b/plugins/workspaces/tests/frontend/workspaces-api.test.ts index 61b3ea2c4..7b2865a66 100644 --- a/plugins/workspaces/tests/frontend/workspaces-api.test.ts +++ b/plugins/workspaces/tests/frontend/workspaces-api.test.ts @@ -46,73 +46,88 @@ const samplePayload: WorkspacePayload = { }; describe("workspaces-api", () => { - it("listWorkspaces GETs /workspaces", async () => { + it("listWorkspaces GETs /plugin-api/workspaces", async () => { authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] }); const result = await listWorkspaces(); - expect(authApiMock.get).toHaveBeenCalledWith("/workspaces"); + expect(authApiMock.get).toHaveBeenCalledWith("/plugin-api/workspaces"); 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({ name: "Test A", color: "#fff", payload: samplePayload, }); - expect(authApiMock.post).toHaveBeenCalledWith("/workspaces", { + expect(authApiMock.post).toHaveBeenCalledWith("/plugin-api/workspaces", { name: "Test A", color: "#fff", payload: samplePayload, }); }); - it("renameWorkspace PATCHes /workspaces/:id", async () => { + it("renameWorkspace PATCHes /plugin-api/workspaces/:id", async () => { await renameWorkspace(5, { name: "New Name" }); - expect(authApiMock.patch).toHaveBeenCalledWith("/workspaces/5", { + expect(authApiMock.patch).toHaveBeenCalledWith("/plugin-api/workspaces/5", { name: "New Name", }); }); - it("updateWorkspaceContent PUTs /workspaces/:id/content", async () => { + it("updateWorkspaceContent PUTs /plugin-api/workspaces/:id/content", async () => { await updateWorkspaceContent(5, samplePayload); - expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/5/content", { - payload: samplePayload, - }); + expect(authApiMock.put).toHaveBeenCalledWith( + "/plugin-api/workspaces/5/content", + { + payload: samplePayload, + }, + ); }); - it("deleteWorkspace DELETEs /workspaces/:id", async () => { + it("deleteWorkspace DELETEs /plugin-api/workspaces/:id", async () => { 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)"); - expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/duplicate", { - name: "Test A (copy)", - }); + expect(authApiMock.post).toHaveBeenCalledWith( + "/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); - 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); - 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 }); const result = await getLastSessionWorkspace(); - expect(authApiMock.get).toHaveBeenCalledWith("/workspaces/last-session"); + expect(authApiMock.get).toHaveBeenCalledWith( + "/plugin-api/workspaces/last-session", + ); expect(result).toBeNull(); }); - it("saveLastSessionWorkspace PUTs /workspaces/last-session", async () => { + it("saveLastSessionWorkspace PUTs /plugin-api/workspaces/last-session", async () => { await saveLastSessionWorkspace(samplePayload); - expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/last-session", { - payload: samplePayload, - }); + expect(authApiMock.put).toHaveBeenCalledWith( + "/plugin-api/workspaces/last-session", + { + payload: samplePayload, + }, + ); }); }); diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index d9b065172..20a4f60c2 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -82,7 +82,6 @@ "plugins/network-topology/src/backend/routes.ts", "plugins/proxmox/src/backend/routes.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/token-service.ts", "plugins/ssh-terminal/src/backend/index.ts", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 44cb4ee97..2a0fccea3 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -10,32 +10,25 @@ import hostRoutes from "./routes/host.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.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 terminalRoutes from "./routes/terminal.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 collabRoutes from "../hosts/collab/routes.js"; -import networkTopologyDispatch from "./routes/network-topology-dispatch.js"; import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; import userPreferencesRoutes from "./routes/user-preferences.js"; import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js"; -import proxmoxDispatch from "./routes/proxmox-dispatch.js"; import termixIdRoutes from "./routes/termix-id.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; -import tailscaleDispatch from "./routes/tailscale-dispatch.js"; import vaultRoutes from "./routes/vault.js"; import secretSourceRoutes from "./routes/secret-sources.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 pluginApiRoutes from "./routes/plugin-api-routes.js"; +import { attachPluginWebSockets } from "../plugins/ws.js"; import pluginRoutes from "./routes/plugins.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js"; @@ -263,8 +256,23 @@ async function fetchGitHubAPI( } } -app.use(bodyParser.json({ limit: "2mb" })); -app.use(bodyParser.urlencoded({ limit: "2mb", extended: true })); +// Skipped for /plugin-api: a plugin router brings its own parsers with its own +// 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((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); @@ -1753,29 +1761,21 @@ app.use("/host", hostRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); -app.use("/fleets", fleetsDispatch); -app.use("/workspaces", workspaceDispatch); app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); app.use("/session_logs", sessionLogRoutes); -app.use("/guacamole", guacamoleDispatch); app.use("/session-sharing", sessionSharingRoutes); app.use("/collab", collabRoutes); -app.use("/network-topology", networkTopologyDispatch); app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); app.use("/user-preferences", userPreferencesRoutes); app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); app.use("/ui-preferences", uiPreferencesRoutes); -app.use("/proxmox", proxmoxDispatch); app.use("/termix-id", termixIdRoutes); registerAuditLogRoutes(app, authenticateJWT); -app.use("/tailscale", tailscaleDispatch); app.use("/vault", vaultRoutes); app.use("/secret-sources", secretSourceRoutes); -app.use("/automations", automationsDispatch); -app.use("/ai", aiDispatch); app.use("/", notificationChannelsRoutes); app.use("/sync", syncRoutes); app.use("/plugins", pluginRoutes); @@ -2028,6 +2028,10 @@ app.get( const httpServer = http.createServer(app); +// Plugin sockets ride this server at /plugin-ws//. Anything else is +// left alone, so core's own upgrade handling is unaffected. +attachPluginWebSockets(httpServer); + httpServer.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { databaseLogger.error( @@ -2076,6 +2080,8 @@ if ( app, ); + attachPluginWebSockets(httpsServer); + httpsServer.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { databaseLogger.error( diff --git a/src/backend/database/routes/ai-dispatch.ts b/src/backend/database/routes/ai-dispatch.ts deleted file mode 100644 index fd7d64907..000000000 --- a/src/backend/database/routes/ai-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/automation-dispatch.ts b/src/backend/database/routes/automation-dispatch.ts deleted file mode 100644 index ba6d6249a..000000000 --- a/src/backend/database/routes/automation-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/fleet-dispatch.ts b/src/backend/database/routes/fleet-dispatch.ts deleted file mode 100644 index 9adc2bd10..000000000 --- a/src/backend/database/routes/fleet-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/guacamole-dispatch.ts b/src/backend/database/routes/guacamole-dispatch.ts deleted file mode 100644 index 09ec8186c..000000000 --- a/src/backend/database/routes/guacamole-dispatch.ts +++ /dev/null @@ -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; - 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 { - 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); -} diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index 473291d01..42fd69674 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -1,6 +1,7 @@ import type { Request, RequestHandler, Response, Router } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger, sshLogger } from "../../utils/logger.js"; +import { pluginEvents, TOPICS } from "../../plugins/events.js"; import { createCurrentCommandHistoryRepository, createCurrentCredentialRepository, @@ -23,7 +24,6 @@ type HostFolderRoutesDeps = { requireDeletePermission: RequestHandler; requireCredentialEditPermission: RequestHandler; requireDataAccess: RequestHandler; - statsServerUrl: string; }; export function registerHostFolderRoutes( @@ -35,7 +35,6 @@ export function registerHostFolderRoutes( requireDeletePermission, requireCredentialEditPermission, requireDataAccess, - statsServerUrl, }: HostFolderRoutesDeps, ): void { /** @@ -444,30 +443,11 @@ export function registerHostFolderRoutes( ); try { - const axios = (await import("axios")).default; for (const host of hostsToDelete) { - try { - 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), - }); - } + pluginEvents.emit(TOPICS.hostDeleted, { hostId: host.id, userId }); } } catch (err) { - sshLogger.warn("Failed to notify stats server of folder deletion", { + sshLogger.warn("Failed to publish host deletion events", { operation: "folder_hosts_delete", folderName, error: err instanceof Error ? err.message : String(err), diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 78db52079..da4ef7051 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -3,9 +3,9 @@ import { applyFolderAccessRules } from "../../utils/folder-access-inheritance.js import { findUsableCredential } from "../../hosts/usable-credential.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import express, { type Request, type Response } from "express"; -import axios from "axios"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; +import { pluginEvents, TOPICS } from "../../plugins/events.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -82,32 +82,29 @@ const router = express.Router(); 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( hostId: number, - headers: Pick, + userId: string, operation: string, ): void { - axios - .post( - `${STATS_SERVER_URL}/host-updated`, - { hostId }, - { - headers: { - Authorization: headers.authorization || "", - Cookie: headers.cookie || "", - }, - timeout: 5000, - }, - ) - .catch((err) => { - sshLogger.warn("Failed to notify stats server of host update", { - operation, - hostId, - error: err instanceof Error ? err.message : String(err), - }); + try { + // The user travels with the event: a subscriber re-reading the host needs + // a data key, and the bus carries no session of its own. + pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); + } catch (err) { + sshLogger.warn("Failed to publish host update event", { + operation, + hostId, + error: err instanceof Error ? err.message : String(err), }); + } } const authManager = AuthManager.getInstance(); @@ -566,11 +563,7 @@ router.post( ); res.json(stripSensitiveFields(resolvedHost)); - notifyStatsHostUpdated( - createdHost.id as number, - req.headers, - "host_create", - ); + notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { operation: "host_create", @@ -1483,7 +1476,7 @@ router.put( }); res.json(stripSensitiveFields(resolvedHost)); - notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update"); + notifyStatsHostUpdated(parseInt(hostId), userId, "host_update"); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { operation: "host_update", @@ -2551,20 +2544,12 @@ router.delete( }); try { - const axios = (await import("axios")).default; - await axios.post( - `${STATS_SERVER_URL}/host-deleted`, - { hostId: numericHostId }, - { - headers: { - Authorization: req.headers.authorization || "", - Cookie: req.headers.cookie || "", - }, - timeout: 5000, - }, - ); + pluginEvents.emit(TOPICS.hostDeleted, { + hostId: numericHostId, + userId, + }); } catch (err) { - sshLogger.warn("Failed to notify stats server of host deletion", { + sshLogger.warn("Failed to publish host deletion event", { operation: "host_delete", hostId: numericHostId, error: err instanceof Error ? err.message : String(err), @@ -2871,7 +2856,6 @@ registerHostFolderRoutes(router, { requireCredentialEditPermission: permissionManager.requirePermission("credentials.edit"), requireDataAccess, - statsServerUrl: STATS_SERVER_URL, }); registerHostBulkRoutes( diff --git a/src/backend/database/routes/network-topology-dispatch.ts b/src/backend/database/routes/network-topology-dispatch.ts deleted file mode 100644 index 10db70dba..000000000 --- a/src/backend/database/routes/network-topology-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/plugin-api-routes.ts b/src/backend/database/routes/plugin-api-routes.ts index e41333a3b..974b09f0d 100644 --- a/src/backend/database/routes/plugin-api-routes.ts +++ b/src/backend/database/routes/plugin-api-routes.ts @@ -1,58 +1,34 @@ -// Mounted at /plugin-api. Every installed plugin that exposes backend routes -// registers a sub-router here under its own plugin id, and this dispatcher -// forwards matching requests to it. The plugin runtime populates the map via -// registerPluginRouter when a plugin activates, and clears it on deactivate, -// so an inactive plugin falls back to the 404 below. +// Mounted at /plugin-api. Every plugin that serves HTTP registers a router +// through ctx.http.router(), and this dispatcher forwards requests to it by +// plugin id. The routers themselves live in src/backend/plugins/http.ts, which +// also owns the middleware stack in front of them: auth, the actor, the +// enabled check, body limits and the error wrapper. // -// WebSocket note: plugin WS traffic does NOT get its own port from the -// 30001-30006 range. It rides the existing WS servers (e.g. the terminal or -// tunnel WS servers) using a named-channel envelope, the same way other -// features multiplex over a shared connection. This is a settled decision, -// not a placeholder, so it should not need revisiting when plugin WS support -// is implemented. +// The dispatcher stays deliberately thin. It resolves a plugin id to a router +// and nothing else, so there is no place here for a check keyed on a +// particular plugin. +// +// WebSocket note: plugin sockets do NOT ride this router, and they do not get +// their own port either. They are served at /plugin-ws// 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 { getPluginRouter } from "../../plugins/http.js"; const router = express.Router(); -const activePluginRouters = new Map(); - -/** - * 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 * /plugin-api/{pluginId}/{path}: * get: * summary: Dispatch a request to an installed plugin's backend router * description: > - * Forwards the request to the sub-router registered by the plugin with - * the given id. Returns 404 if the plugin is not installed or not + * Forwards the request to the router the plugin with the given id + * 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. * tags: * - Plugins @@ -62,15 +38,24 @@ export function getRegisteredPluginIds(): string[] { * required: true * schema: * type: string + * - in: path + * name: path + * required: true + * schema: + * type: string * responses: * 200: * description: Response from the plugin's router. + * 401: + * description: Authentication required. * 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) => { const pluginId = String(req.params.pluginId); - const pluginRouter = activePluginRouters.get(pluginId); + const pluginRouter = getPluginRouter(pluginId); if (!pluginRouter) { databaseLogger.warn("Plugin API request for unregistered plugin", { diff --git a/src/backend/database/routes/proxmox-dispatch.ts b/src/backend/database/routes/proxmox-dispatch.ts deleted file mode 100644 index 173dd8a06..000000000 --- a/src/backend/database/routes/proxmox-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/tailscale-dispatch.ts b/src/backend/database/routes/tailscale-dispatch.ts deleted file mode 100644 index 8915750d3..000000000 --- a/src/backend/database/routes/tailscale-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index a738786f0..d11f50322 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -6,7 +6,7 @@ import type { RequestHandler, Router } from "express"; // importing the plugin's TypeScript directly, since plugins/ is compiled // separately from src/backend/ and core cannot statically import across that // 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 { authLogger, getGlobalLogLevel, diff --git a/src/backend/database/routes/workspace-dispatch.ts b/src/backend/database/routes/workspace-dispatch.ts deleted file mode 100644 index 59b2a86c1..000000000 --- a/src/backend/database/routes/workspace-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/hosts/collab/routes.ts b/src/backend/hosts/collab/routes.ts index 13949b142..764369dda 100644 --- a/src/backend/hosts/collab/routes.ts +++ b/src/backend/hosts/collab/routes.ts @@ -15,7 +15,7 @@ import { // src/backend/database/routes/guacamole-dispatch.ts and that plugin's // README -- this is an accepted, temporary coupling until collab becomes a // 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 { getStageController, setStageController } from "./stage-control.js"; import { isCollabGuestRateLimited } from "./guest-rate-limit.js"; @@ -1091,7 +1091,7 @@ router.get("/guest/:token", async (req: Request, res: Response) => { shareId: share.id, ...(protocol === "ssh" ? { - wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(token)}`, + wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(token)}`, } : { connectParams: { diff --git a/src/backend/hosts/guacamole-sessions.ts b/src/backend/hosts/guacamole-sessions.ts new file mode 100644 index 000000000..6196e4619 --- /dev/null +++ b/src/backend/hosts/guacamole-sessions.ts @@ -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; + createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string; + getSessionInfo: ( + guacamoleConnectionId: string, + ) => GuacamoleSessionInfo | null; +} + +function provider(): GuacamoleSessionProvider | undefined { + return consume(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 { + 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; +} diff --git a/src/backend/hosts/opkssh-auth.ts b/src/backend/hosts/opkssh-auth.ts index 9bbbbf2bf..634b93d6a 100644 --- a/src/backend/hosts/opkssh-auth.ts +++ b/src/backend/hosts/opkssh-auth.ts @@ -544,10 +544,13 @@ function handleOPKSSHOutput(requestId: string, output: string): void { session.localPort = actualPort; - const baseUrl = session.remoteRedirectUri - .replace(/\/host\/opkssh-callback$/, "") - // In direct dev mode the WS server (30002) is separate from the HTTP API (30001) - .replace(/:30002\b/, ":30001"); + // No port rewrite any more: the terminal socket used to live on 30002, + // separate from the HTTP API, so a redirect URI derived from it pointed at + // the wrong server in direct dev mode. The terminal rides 30001 now. + const baseUrl = session.remoteRedirectUri.replace( + /\/host\/opkssh-callback$/, + "", + ); const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`; session.status = "waiting_for_auth"; diff --git a/src/backend/hosts/session-sharing/live-sessions.ts b/src/backend/hosts/session-sharing/live-sessions.ts index 7a0eaf643..5ce82d297 100644 --- a/src/backend/hosts/session-sharing/live-sessions.ts +++ b/src/backend/hosts/session-sharing/live-sessions.ts @@ -6,7 +6,7 @@ import { sessionManager } from "../terminal/session-manager.js"; // src/backend/database/routes/guacamole-dispatch.ts and that plugin's // README -- this is an accepted, temporary coupling until collab/ // session-sharing themselves become plugins. -import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../../database/routes/guacamole-dispatch.js"; +import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../guacamole-sessions.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, diff --git a/src/backend/hosts/session-sharing/routes.ts b/src/backend/hosts/session-sharing/routes.ts index fbcd6cfee..b7585c860 100644 --- a/src/backend/hosts/session-sharing/routes.ts +++ b/src/backend/hosts/session-sharing/routes.ts @@ -17,7 +17,7 @@ import { // src/backend/database/routes/guacamole-dispatch.ts and that plugin's // README -- this is an accepted, temporary coupling until session-sharing // 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"; const router = express.Router(); @@ -405,10 +405,13 @@ router.get("/resolve/:linkToken", async (req: Request, res: Response) => { } = { protocol, 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: protocol === "ssh" - ? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}` - : "/guacamole/websocket/", + ? `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(linkToken)}` + : "/plugin-ws/remote-desktop/display", }; if (protocol !== "ssh") { diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 8dad211ea..e554e6662 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -139,68 +139,49 @@ const TAILSCALE_CHECK_TIMEOUT_MS = 1_800_000; const userConnections = new Map>(); -export const TERMINAL_WS_PORT = 30002; - // Re-exported so the ssh-terminal plugin can publish it on the service // registry. session-manager itself stays in core: six modules outside the // terminal import it directly. export { sessionManager }; /** - * The server is created but not listening until startTerminalServer() runs. - * The ssh-terminal plugin owns that lifecycle, so disabling the plugin has to - * actually free the port rather than just stop routing to it. + * Serves /plugin-ws/ssh-terminal/terminal through the main backend server. * - * `noServer` avoids binding at import time; listen() below is what opens the - * port. Handlers are attached at module scope as before, so none of the + * `noServer` because core owns the listening socket now: the ssh-terminal + * 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. */ const wss = new WebSocketServer({ noServer: true }); -let httpServer: import("http").Server | null = null; - wss.on("error", (error) => { sshLogger.error("WebSocket server error", error, { operation: "wss_error", }); }); -export async function startTerminalServer( - port: number = TERMINAL_WS_PORT, -): Promise { - if (httpServer) return; - - const { createServer } = await import("http"); - const server = createServer(); - - server.on("upgrade", (req, socket, head) => { - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); - }); - }); - - await new Promise((resolve, reject) => { - 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", +/** + * Hands one upgrade to the terminal's own WebSocketServer. + * + * The connection handler below authenticates each socket itself, because a + * share-link guest arrives with a share token rather than a session. So this + * is registered as a public ctx.ws route and the auth stays where it already + * is, rather than being split across two places. + */ +export function handleTerminalUpgrade( + req: import("http").IncomingMessage, + socket: import("stream").Duplex, + head: Buffer, +): void { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); }); } export async function stopTerminalServer(): Promise { - if (!httpServer) return; - - const server = httpServer; - httpServer = null; - - // Close live sockets first: server.close() waits for connections to drain, - // and a terminal session would otherwise hold the port open indefinitely. + // 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 + // otherwise outlive the plugin that owns it. for (const ws of wss.clients) { try { ws.close(1001, "Terminal plugin disabled"); @@ -209,8 +190,7 @@ export async function stopTerminalServer(): Promise { } } - await new Promise((resolve) => server.close(() => resolve())); - sshLogger.info("Terminal WebSocket server stopped", { + sshLogger.info("Terminal WebSocket sessions closed", { operation: "terminal_ws_stop", }); } diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts index a4504efe5..a29176356 100644 --- a/src/backend/hosts/tunnel/index.ts +++ b/src/backend/hosts/tunnel/index.ts @@ -21,7 +21,6 @@ import { } from "./c2s-relay.js"; import { registerTunnelRoutes } from "./routes.js"; -import webEndpointDispatch from "./web-endpoint-dispatch.js"; import { initializeAutoStartTunnels } from "./manager.js"; import { attachServicePortConflictHandler } from "../../utils/service-listen.js"; @@ -39,9 +38,6 @@ app.use((_req, res, next) => { }); 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 server = createServer(app); diff --git a/src/backend/hosts/tunnel/web-endpoint-dispatch.ts b/src/backend/hosts/tunnel/web-endpoint-dispatch.ts deleted file mode 100644 index 1e3c58fcb..000000000 --- a/src/backend/hosts/tunnel/web-endpoint-dispatch.ts +++ /dev/null @@ -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); -} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index d2c8dc242..ad552bfdf 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -29,8 +29,18 @@ import type { SecretRegistration } from "./secret-registry.js"; import { assertCapability } from "./permissions.js"; import { getActor, runAsActor } from "./actor.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 { 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 * 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: { 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 { + 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 { if (typeof key !== "string" || key.length === 0) { throw new Error("ctx.kv keys must be non-empty strings"); diff --git a/src/backend/plugins/events.ts b/src/backend/plugins/events.ts index 281c6c30f..c71fb3e9d 100644 --- a/src/backend/plugins/events.ts +++ b/src/backend/plugins/events.ts @@ -34,6 +34,12 @@ export const TOPICS = { hostStatus: "host.status", /** A health check result. */ 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; export type EventListener = (payload: unknown) => void; diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts new file mode 100644 index 000000000..781c760e4 --- /dev/null +++ b/src/backend/plugins/http.ts @@ -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//. 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(); + +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 { + 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; +} diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 234bcd368..10f2647d3 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -10,7 +10,7 @@ */ 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 { invalidatePluginPermissionCache } from "./permissions.js"; @@ -19,6 +19,12 @@ let loader: PluginLoader | null = null; export function getPluginRuntime(): { loader: PluginLoader } { if (!loader) { 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 }; } @@ -336,7 +342,7 @@ export async function deactivatePlugin(pluginId: string): Promise { const plugin = pluginLoader.get(pluginId); await pluginLoader.deactivate(pluginId); - unregisterPluginRouter(pluginId); + unregisterPluginHttp(pluginId); const group = plugin?.manifest.contributes?.permissionGroup; if (group) { @@ -348,7 +354,7 @@ export async function deactivatePlugin(pluginId: string): Promise { export async function shutdownPlugins(): Promise { if (!loader) return; - for (const plugin of loader.list()) unregisterPluginRouter(plugin.id); + for (const plugin of loader.list()) unregisterPluginHttp(plugin.id); await loader.shutdown(); } diff --git a/src/backend/plugins/ws.ts b/src/backend/plugins/ws.ts new file mode 100644 index 000000000..b8f50e38e --- /dev/null +++ b/src/backend/plugins/ws.ts @@ -0,0 +1,317 @@ +/** + * WebSockets for plugins: one upgrade handler, no ports. + * + * A plugin registers /plugin-ws// 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 ":". */ +const routes = new Map(); + +/** One server per plugin route, so disposal can close exactly its sockets. */ +const servers = new Map(); + +const attachedServers = new Set(); + +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// 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 { + 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 { + 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(); +} diff --git a/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts b/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts deleted file mode 100644 index 72e56d538..000000000 --- a/src/backend/tests/database/routes/plugin-api-routes-auth.test.ts +++ /dev/null @@ -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 { - 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, - }); - }); -}); diff --git a/src/backend/tests/hosts/collab/routes.test.ts b/src/backend/tests/hosts/collab/routes.test.ts index b49c866d1..41fe16932 100644 --- a/src/backend/tests/hosts/collab/routes.test.ts +++ b/src/backend/tests/hosts/collab/routes.test.ts @@ -56,7 +56,7 @@ vi.mock("../../../utils/audit-logger.js", () => ({ getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()), 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) => `join:${id}:${readOnly}`, })); @@ -646,7 +646,7 @@ describe("collab room routes", () => { await present(roomId, "s1"); expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({ protocol: "ssh", - wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`, + wsPath: `/plugin-ws/ssh-terminal/terminal?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`, }); state.liveOwned.set("g1", "host-1"); await present(roomId, "g1", "vnc"); diff --git a/src/backend/tests/hosts/session-sharing/routes.test.ts b/src/backend/tests/hosts/session-sharing/routes.test.ts index 8c9047fdb..e98d7c5e2 100644 --- a/src/backend/tests/hosts/session-sharing/routes.test.ts +++ b/src/backend/tests/hosts/session-sharing/routes.test.ts @@ -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) => state.guacSessions.get(guacamoleConnectionId) ?? null, createGuacamoleJoinToken: ( @@ -364,7 +364,7 @@ describe("GET /session-sharing/resolve/:linkToken", () => { expect(res.jsonBody).toEqual({ protocol: "ssh", permissionLevel: "read-only", - wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`, + wsPath: `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(share.linkToken)}`, }); }); diff --git a/src/backend/tests/hosts/terminal/server-lifecycle.test.ts b/src/backend/tests/hosts/terminal/server-lifecycle.test.ts index c33ef88a1..dcfb29ce5 100644 --- a/src/backend/tests/hosts/terminal/server-lifecycle.test.ts +++ b/src/backend/tests/hosts/terminal/server-lifecycle.test.ts @@ -1,14 +1,18 @@ /** - * The terminal WS server's start/stop lifecycle, which the ssh-terminal plugin - * drives. The point of this test is the observable consequence of disabling - * the plugin: the port actually stops listening. + * The terminal WS server's lifecycle, which the ssh-terminal plugin drives. + * + * 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 - * exercises only the lifecycle functions, on a non-default port. + * exercises only the lifecycle surface. */ -import { afterEach, describe, expect, it, vi } from "vitest"; -import net from "node:net"; +import { describe, expect, it, vi } from "vitest"; vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentHostResolutionRepository: () => ({}), @@ -18,65 +22,29 @@ vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSessionRecordingRepository: () => ({}), })); -const TEST_PORT = 39002; - -function isListening(port: number): Promise { - 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", () => { - 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 } = await import("../../../hosts/terminal/index.js"); - await stopTerminalServer(); - }); - it("does not listen until it is started", async () => { - await import("../../../hosts/terminal/index.js"); - // 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); + await expect(stopTerminalServer()).resolves.toBeUndefined(); + await expect(stopTerminalServer()).resolves.toBeUndefined(); }); }); diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts new file mode 100644 index 000000000..5a7ee065b --- /dev/null +++ b/src/backend/tests/plugins/http.test.ts @@ -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(["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 = {}) { + 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 { + 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((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"); + }); +}); diff --git a/src/backend/tests/plugins/ws.test.ts b/src/backend/tests/plugins/ws.test.ts new file mode 100644 index 000000000..7f5c45ec5 --- /dev/null +++ b/src/backend/tests/plugins/ws.test.ts @@ -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(["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 { + 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((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(); + }); +}); diff --git a/src/backend/tests/utils/login-event-dispatch.test.ts b/src/backend/tests/utils/login-event-dispatch.test.ts index a15023108..91f54e6fc 100644 --- a/src/backend/tests/utils/login-event-dispatch.test.ts +++ b/src/backend/tests/utils/login-event-dispatch.test.ts @@ -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 { 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"; describe("dispatchLoginEvent", () => { @@ -8,37 +17,24 @@ describe("dispatchLoginEvent", () => { vi.restoreAllMocks(); }); - it("reports a rejected metrics-service request", async () => { - vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ - getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), - } as never); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response('{"error":"Missing authentication token"}', { - status: 401, - }), - ); - const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + it("publishes the login details a subscriber needs", async () => { + const received: unknown[] = []; + const unsubscribe = pluginEvents.on(TOPICS.hostLogin, (payload) => { + received.push(payload); + }); - 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( - "Failed to dispatch login event", - expect.objectContaining({ - operation: "login_event_dispatch_error", - hostId: 7, - error: - 'Metrics service returned 401: {"error":"Missing authentication token"}', - }), - ); + expect(received).toEqual([ + { hostId: 42, userId: "user-1", sshUser: "root", fromIp: "10.0.0.5" }, + ]); }); - it("does not log a warning when the metrics service accepts the event", 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 }), - ); + it("does not log a warning on the happy path", async () => { const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1"); @@ -46,49 +42,27 @@ describe("dispatchLoginEvent", () => { expect(warn).not.toHaveBeenCalled(); }); - it("sends the internal auth token and login details the metrics service expects", async () => { - vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ - getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), - } as never); - const fetchSpy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + it("never sends the event over the network", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); - await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5"); + await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1"); - expect(fetchSpy).toHaveBeenCalledWith( - "http://localhost:30005/internal/login-alert", - expect.objectContaining({ - 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", - }), - }), - ); + // The whole point of the move: no service-to-service HTTP call, and no + // internal shared secret to carry on it. + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("logs a warning if the fetch itself throws, instead of propagating", async () => { - vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ - getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), - } as never); - vi.spyOn(globalThis, "fetch").mockRejectedValue( - new Error("connect ECONNREFUSED"), - ); - const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + it("does not propagate a subscriber's throw to the caller", async () => { + const unsubscribe = pluginEvents.on(TOPICS.hostLogin, () => { + throw new Error("subscriber exploded"); + }); - await expect( - dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"), - ).resolves.toBeUndefined(); - - expect(warn).toHaveBeenCalledWith( - "Failed to dispatch login event", - expect.objectContaining({ hostId: 1 }), - ); + try { + await expect( + dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"), + ).resolves.toBeUndefined(); + } finally { + unsubscribe(); + } }); }); diff --git a/src/backend/utils/login-event-dispatch.ts b/src/backend/utils/login-event-dispatch.ts index cd3ec4ea3..4fc5e950c 100644 --- a/src/backend/utils/login-event-dispatch.ts +++ b/src/backend/utils/login-event-dispatch.ts @@ -1,8 +1,14 @@ -import { SystemCrypto } from "./system-crypto.js"; +import { pluginEvents, TOPICS } from "../plugins/events.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( hostId: number, userId: string, @@ -10,24 +16,7 @@ export async function dispatchLoginEvent( fromIp: string, ): Promise { try { - const token = await SystemCrypto.getInstance().getInternalAuthToken(); - 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}` : ""}`, - ); - } + pluginEvents.emit(TOPICS.hostLogin, { hostId, userId, sshUser, fromIp }); } catch (err) { sshLogger.warn("Failed to dispatch login event", { operation: "login_event_dispatch_error", diff --git a/src/backend/utils/swagger.ts b/src/backend/utils/swagger.ts index a9fb2640e..bf7eb6d47 100644 --- a/src/backend/utils/swagger.ts +++ b/src/backend/utils/swagger.ts @@ -30,18 +30,10 @@ const swaggerOptions: SwaggerJSDocOptions = { url: "http://localhost:30004", description: "SSH file manager server", }, - { - url: "http://localhost:30005", - description: "Server statistics and monitoring server", - }, { url: "http://localhost:30006", description: "Dashboard server", }, - { - url: "http://localhost:30007", - description: "Docker management server", - }, { url: "http://localhost:30011", description: "Serial connection server", diff --git a/src/ui/api/ai-api.ts b/src/ui/api/ai-api.ts index 8188c0b20..cbeb1bf66 100644 --- a/src/ui/api/ai-api.ts +++ b/src/ui/api/ai-api.ts @@ -52,7 +52,7 @@ export interface AiStatus { export async function getAiStatus(): Promise { try { - return (await authApi.get("/ai/status")).data; + return (await authApi.get("/plugin-api/ai/status")).data; } catch (error) { throw handleApiError(error, "get AI status"); } @@ -60,7 +60,7 @@ export async function getAiStatus(): Promise { export async function getAiProviders(): Promise { try { - return (await authApi.get("/ai/providers")).data.providers; + return (await authApi.get("/plugin-api/ai/providers")).data.providers; } catch (error) { throw handleApiError(error, "list AI providers"); } @@ -74,7 +74,8 @@ export async function createAiProvider(input: { defaultModel?: string | null; }): Promise { try { - return (await authApi.post("/ai/providers", input)).data.provider; + return (await authApi.post("/plugin-api/ai/providers", input)).data + .provider; } catch (error) { throw handleApiError(error, "create AI provider"); } @@ -91,7 +92,8 @@ export async function updateAiProvider( }>, ): Promise { 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) { throw handleApiError(error, "update AI provider"); } @@ -99,7 +101,7 @@ export async function updateAiProvider( export async function deleteAiProvider(id: number): Promise { try { - await authApi.delete(`/ai/providers/${id}`); + await authApi.delete(`/plugin-api/ai/providers/${id}`); } catch (error) { throw handleApiError(error, "delete AI provider"); } @@ -120,7 +122,7 @@ export async function probeAiModels(input: { warning?: string; }> { try { - return (await authApi.post("/ai/probe-models", input)).data; + return (await authApi.post("/plugin-api/ai/probe-models", input)).data; } catch (error) { throw handleApiError(error, "detect models"); } @@ -128,7 +130,8 @@ export async function probeAiModels(input: { export async function getAiProviderModels(id: number): Promise { 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) { throw handleApiError(error, "list provider models"); } @@ -136,7 +139,8 @@ export async function getAiProviderModels(id: number): Promise { export async function getAiConversations(): Promise { try { - return (await authApi.get("/ai/conversations")).data.conversations; + return (await authApi.get("/plugin-api/ai/conversations")).data + .conversations; } catch (error) { throw handleApiError(error, "list AI conversations"); } @@ -148,7 +152,7 @@ export async function getAiConversation(id: number): Promise<{ proposals: AiProposal[]; }> { try { - return (await authApi.get(`/ai/conversations/${id}`)).data; + return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data; } catch (error) { throw handleApiError(error, "load AI conversation"); } @@ -156,7 +160,7 @@ export async function getAiConversation(id: number): Promise<{ export async function deleteAiConversation(id: number): Promise { try { - await authApi.delete(`/ai/conversations/${id}`); + await authApi.delete(`/plugin-api/ai/conversations/${id}`); } catch (error) { throw handleApiError(error, "delete AI conversation"); } @@ -166,7 +170,7 @@ export async function applyAiProposal( id: number, ): Promise<{ success: boolean; summary: string }> { try { - return (await authApi.post(`/ai/proposals/${id}/apply`)).data; + return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data; } catch (error) { throw handleApiError(error, "apply AI proposal"); } @@ -174,7 +178,7 @@ export async function applyAiProposal( export async function rejectAiProposal(id: number): Promise { try { - await authApi.post(`/ai/proposals/${id}/reject`); + await authApi.post(`/plugin-api/ai/proposals/${id}/reject`); } catch (error) { throw handleApiError(error, "reject AI proposal"); } @@ -191,10 +195,13 @@ export async function markAiProposalRunInTerminal( ): Promise<{ success: boolean; summary: string }> { try { return ( - await authApi.post(`/ai/proposals/${id}/mark-run-in-terminal`, { - hostId, - summary, - }) + await authApi.post( + `/plugin-api/ai/proposals/${id}/mark-run-in-terminal`, + { + hostId, + summary, + }, + ) ).data; } catch (error) { throw handleApiError(error, "mark AI proposal run in terminal"); @@ -205,7 +212,7 @@ export async function markAiProposalRunInTerminal( export async function getAiGloballyEnabled(): Promise { try { - return (await authApi.get("/ai/enabled")).data.enabled; + return (await authApi.get("/plugin-api/ai/enabled")).data.enabled; } catch (error) { throw handleApiError(error, "get AI enabled setting"); } @@ -213,7 +220,8 @@ export async function getAiGloballyEnabled(): Promise { export async function setAiGloballyEnabled(enabled: boolean): Promise { try { - return (await authApi.patch("/ai/enabled", { enabled })).data.enabled; + return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data + .enabled; } catch (error) { throw handleApiError(error, "update AI enabled setting"); } @@ -221,7 +229,7 @@ export async function setAiGloballyEnabled(enabled: boolean): Promise { export async function getAiPrivateEndpoints(): Promise { try { - return (await authApi.get("/ai/private-endpoints")).data.hosts; + return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts; } catch (error) { throw handleApiError(error, "get AI endpoint allowlist"); } @@ -231,7 +239,8 @@ export async function setAiPrivateEndpoints( hosts: string[], ): Promise { 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) { throw handleApiError(error, "update AI endpoint allowlist"); } diff --git a/src/ui/api/fleets-api.ts b/src/ui/api/fleets-api.ts index d15ececbf..8d87c66bf 100644 --- a/src/ui/api/fleets-api.ts +++ b/src/ui/api/fleets-api.ts @@ -60,7 +60,7 @@ export type FleetPackageAction = "install" | "remove" | "upgrade-all"; export async function listFleets(): Promise { try { - const response = await authApi.get("/fleets"); + const response = await authApi.get("/plugin-api/fleets"); return response.data; } catch (error) { throw handleApiError(error, "fetch fleets"); @@ -75,7 +75,7 @@ export async function createFleet(fleetData: { tagRules?: string[]; }): Promise { try { - const response = await authApi.post("/fleets", fleetData); + const response = await authApi.post("/plugin-api/fleets", fleetData); return response.data; } catch (error) { throw handleApiError(error, "create fleet"); @@ -93,7 +93,10 @@ export async function updateFleet( }, ): Promise { try { - const response = await authApi.patch(`/fleets/${fleetId}`, fleetData); + const response = await authApi.patch( + `/plugin-api/fleets/${fleetId}`, + fleetData, + ); return response.data; } catch (error) { throw handleApiError(error, "update fleet"); @@ -104,7 +107,7 @@ export async function deleteFleet( fleetId: number, ): Promise<{ success: boolean }> { try { - const response = await authApi.delete(`/fleets/${fleetId}`); + const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`); return response.data; } catch (error) { throw handleApiError(error, "delete fleet"); @@ -115,7 +118,7 @@ export async function getFleetMembers( fleetId: number, ): Promise { try { - const response = await authApi.get(`/fleets/${fleetId}/members`); + const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`); return response.data; } catch (error) { throw handleApiError(error, "fetch fleet members"); @@ -127,9 +130,12 @@ export async function addFleetMember( hostId: number, ): Promise<{ success: boolean }> { try { - const response = await authApi.post(`/fleets/${fleetId}/members`, { - hostId, - }); + const response = await authApi.post( + `/plugin-api/fleets/${fleetId}/members`, + { + hostId, + }, + ); return response.data; } catch (error) { throw handleApiError(error, "add fleet member"); @@ -142,7 +148,7 @@ export async function removeFleetMember( ): Promise<{ success: boolean }> { try { const response = await authApi.delete( - `/fleets/${fleetId}/members/${hostId}`, + `/plugin-api/fleets/${fleetId}/members/${hostId}`, ); return response.data; } catch (error) { @@ -156,10 +162,13 @@ export async function runFleetCommand( inputValues?: Record, ): Promise<{ results: FleetHostResult[] }> { try { - const response = await authApi.post(`/fleets/${fleetId}/execute`, { - command, - ...(inputValues ? { inputValues } : {}), - }); + const response = await authApi.post( + `/plugin-api/fleets/${fleetId}/execute`, + { + command, + ...(inputValues ? { inputValues } : {}), + }, + ); return response.data; } catch (error) { throw handleApiError(error, "run fleet command"); @@ -176,7 +185,7 @@ export async function pushFleetFile( form.append("file", file); form.append("remotePath", remotePath); const response = await authApi.post( - `/fleets/${fleetId}/transfer/push`, + `/plugin-api/fleets/${fleetId}/transfer/push`, form, { headers: { "Content-Type": "multipart/form-data" } }, ); @@ -192,7 +201,7 @@ export async function pullFleetFile( ): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> { try { const response = await authApi.post( - `/fleets/${fleetId}/transfer/pull`, + `/plugin-api/fleets/${fleetId}/transfer/pull`, { remotePath }, { responseType: "blob" }, ); @@ -217,7 +226,9 @@ export async function getFleetInventory( fleetId: number, ): Promise { try { - const response = await authApi.get(`/fleets/${fleetId}/inventory`); + const response = await authApi.get( + `/plugin-api/fleets/${fleetId}/inventory`, + ); return response.data; } catch (error) { throw handleApiError(error, "fetch fleet inventory"); @@ -228,7 +239,9 @@ export async function refreshFleetInventory( fleetId: number, ): Promise<{ results: FleetHostResult[] }> { try { - const response = await authApi.post(`/fleets/${fleetId}/inventory`); + const response = await authApi.post( + `/plugin-api/fleets/${fleetId}/inventory`, + ); return response.data; } catch (error) { throw handleApiError(error, "refresh fleet inventory"); @@ -241,10 +254,13 @@ export async function runFleetPackageAction( packageName?: string, ): Promise<{ results: FleetHostResult[] }> { try { - const response = await authApi.post(`/fleets/${fleetId}/packages`, { - action, - ...(packageName ? { package: packageName } : {}), - }); + const response = await authApi.post( + `/plugin-api/fleets/${fleetId}/packages`, + { + action, + ...(packageName ? { package: packageName } : {}), + }, + ); return response.data; } catch (error) { throw handleApiError(error, "run fleet package action"); @@ -273,7 +289,10 @@ export async function shareFleet( hostResults: FleetShareHostResult[]; }> { try { - const response = await authApi.post(`/fleets/${fleetId}/share`, shareData); + const response = await authApi.post( + `/plugin-api/fleets/${fleetId}/share`, + shareData, + ); return response.data; } catch (error) { throw handleApiError(error, "share fleet"); diff --git a/src/ui/api/snippets-api.ts b/src/ui/api/snippets-api.ts index fb63e2585..94ba9b2dc 100644 --- a/src/ui/api/snippets-api.ts +++ b/src/ui/api/snippets-api.ts @@ -102,7 +102,7 @@ export async function executeSnippet( export async function getNetworkTopology(): Promise { try { - const response = await authApi.get("/network-topology/"); + const response = await authApi.get("/plugin-api/network-topology/"); return response.data; } catch (error) { throw handleApiError(error, "fetch network topology"); @@ -113,7 +113,9 @@ export async function saveNetworkTopology( topology: NetworkTopologyData, ): Promise<{ success: boolean }> { try { - const response = await authApi.post("/network-topology/", { topology }); + const response = await authApi.post("/plugin-api/network-topology/", { + topology, + }); return response.data; } catch (error) { throw handleApiError(error, "save network topology"); diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 9f6f2cf64..6bb9a8fb2 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -188,7 +188,7 @@ export async function discoverProxmoxGuests( ): Promise { try { const response = await authApi.post( - "/proxmox/discover", + "/plugin-api/proxmox/discover", { hostId }, { timeout: 120000 }, ); @@ -208,7 +208,7 @@ export function discoverProxmoxGuestsStream( ): () => void { const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, ""); const source = new EventSource( - `${baseURL}/proxmox/discover/stream?hostId=${encodeURIComponent( + `${baseURL}/plugin-api/proxmox/discover/stream?hostId=${encodeURIComponent( String(hostId), )}`, { withCredentials: true }, @@ -257,7 +257,7 @@ export async function syncProxmoxGuests( ): Promise { try { const response = await authApi.post( - "/proxmox/sync", + "/plugin-api/proxmox/sync", { hostId }, { timeout: 120000 }, ); diff --git a/src/ui/features/collab/CollabRoomTab.tsx b/src/ui/features/collab/CollabRoomTab.tsx index 0c19ad88b..84fe8b3b8 100644 --- a/src/ui/features/collab/CollabRoomTab.tsx +++ b/src/ui/features/collab/CollabRoomTab.tsx @@ -38,8 +38,7 @@ import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/sr import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios"; import { getRoles } from "@/api/rbac-api"; import type { Role } from "@/main-axios"; -import { getBasePath } from "@/lib/base-path"; -import { isElectron } from "@/lib/electron"; +import { pluginWsUrl } from "@/lib/plugin-transport"; import { getErrorMessage } from "@/lib/error-message"; import { deleteCollabRoom, @@ -62,23 +61,11 @@ import { resolveConnectionOrigin } from "@/lib/connection-origin"; const PING_INTERVAL_MS = 30000; const POLL_FALLBACK_MS = 15000; -// Mirrors SharedSessionView's construction (dev/electron/prod); authentication -// rides on the jwt cookie the way every terminal WS connection does. -function roomEventsWsUrl(): string { - const isDev = - !isElectron() && - 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/`; +// The terminal socket the ssh-terminal plugin serves. Authentication rides on +// the jwt cookie the way every terminal WS connection does. +async function roomEventsWsUrl(): Promise { + const target = await pluginWsUrl("ssh-terminal", "/terminal"); + return target?.url ?? null; } type PresentDraft = @@ -194,13 +181,16 @@ export function CollabRoomTab({ if (pollTimer) clearInterval(pollTimer); pollTimer = null; }; - const connect = () => { + const connect = async () => { if (cancelled) return; 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 { startPolling(); - reconnectTimer = setTimeout(connect, 5000); + reconnectTimer = setTimeout(() => void connect(), 5000); return; } ws.onopen = () => { @@ -246,12 +236,12 @@ export function CollabRoomTab({ pingTimer = null; startPolling(); const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000); - reconnectTimer = setTimeout(connect, delay); + reconnectTimer = setTimeout(() => void connect(), delay); }; ws.onerror = () => ws?.close(); }; - connect(); + void connect(); return () => { cancelled = true; if (pingTimer) clearInterval(pingTimer); diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx index 126d0c426..f402ca871 100644 --- a/src/ui/features/session-sharing/SharedSessionView.tsx +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -9,8 +9,7 @@ import { type ShareLinkErrorKind, } from "@/api/session-sharing-api"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; -import { getBasePath } from "@/lib/base-path"; -import { isElectron } from "@/lib/electron"; +import { pluginWsUrl } from "@/lib/plugin-transport"; import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx"; const PING_INTERVAL_MS = 30000; @@ -27,21 +26,9 @@ interface TerminalWsMessage { // embedded local backend -- joining a session hosted on someone else's // remote server isn't supported from the desktop app today. async function resolveTerminalWsBaseUrl(): Promise { - const isDev = - !isElectron() && - 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/`; + const target = await pluginWsUrl("ssh-terminal", "/terminal"); + if (!target) throw new Error("No terminal endpoint is available"); + return target.url; } export interface SessionParticipantInfo { diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index ff129b7d2..03aca3fe7 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -27,11 +27,8 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon } from "@xterm/addon-search"; import { useTranslation } from "react-i18next"; -import { getBasePath } from "@/lib/base-path"; -import { - resolveConnectionOrigin, - buildOriginWsUrl, -} from "@/lib/connection-origin.ts"; +import { resolveConnectionOrigin } from "@/lib/connection-origin.ts"; +import { pluginWsUrl } from "@/lib/plugin-transport"; import { getCookie, isElectron, @@ -1536,30 +1533,20 @@ const TerminalInner = forwardRef( 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 wsProtocols: string[] = []; let outboundHostConfig = hostConfig; - if (isDev) { - baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; - } else if (isElectron()) { - const origin = await resolveConnectionOrigin({ - connectionType: "ssh", - connectionOrigin: hostConfig.connectionOrigin as - "local" | "remote" | null | undefined, - }); - const resolvedUrl = await buildOriginWsUrl({ + { + const origin = isElectron() + ? await resolveConnectionOrigin({ + connectionType: "ssh", + connectionOrigin: hostConfig.connectionOrigin as + "local" | "remote" | null | undefined, + }) + : "local"; + const resolvedUrl = await pluginWsUrl("ssh-terminal", "/terminal", { origin, - localPort: 30002, - localPath: "", - remotePath: "/ssh/websocket/", }); if (!resolvedUrl) { setIsConnected(false); @@ -1568,7 +1555,7 @@ const TerminalInner = forwardRef( isConnectingRef.current = false; return; } - if (origin === "local") { + if (isElectron() && origin === "local") { try { outboundHostConfig = await hydrateLocalSharedHostAuth(hostConfig); } catch (error) { @@ -1586,8 +1573,6 @@ const TerminalInner = forwardRef( } baseWsUrl = resolvedUrl.url; wsProtocols = resolvedUrl.protocols; - } else { - baseWsUrl = `${getBasePath()}/ssh/websocket/`; } if ( diff --git a/src/ui/lib/plugin-transport.ts b/src/ui/lib/plugin-transport.ts new file mode 100644 index 000000000..5963b1e61 --- /dev/null +++ b/src/ui/lib/plugin-transport.ts @@ -0,0 +1,126 @@ +/** + * How a plugin frontend reaches its own backend. + * + * Every plugin serves HTTP under /plugin-api// and WebSockets under + * /plugin-ws// 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//, 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 { + 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, + }; +} diff --git a/src/ui/lib/remote-server-api.ts b/src/ui/lib/remote-server-api.ts index b8da4f625..38e4c6b0b 100644 --- a/src/ui/lib/remote-server-api.ts +++ b/src/ui/lib/remote-server-api.ts @@ -1,5 +1,5 @@ import type { AxiosInstance } from "axios"; -import { getRemoteStatsApi } from "@/main-axios"; +import { getRemoteCoreApi } from "@/main-axios"; import { isElectron } from "@/lib/electron"; import type { SSHHost } from "@/types/index"; @@ -77,7 +77,7 @@ export async function getConnectedRemoteApi(): Promise { const config = (await window.electronAPI?.invoke?.( "get-remote-sync-config", )) as { serverUrl?: string } | null; - return config?.serverUrl ? getRemoteStatsApi() : null; + return config?.serverUrl ? getRemoteCoreApi() : null; } catch { return null; } diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 7f119008d..a21841554 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -833,6 +833,7 @@ function createRemoteOriginApiInstance(path: string): AxiosInstance { let remoteFileManagerApi: AxiosInstance | null = null; let remoteTunnelApi: AxiosInstance | null = null; let remoteStatsApi: AxiosInstance | null = null; +let remoteCoreApi: AxiosInstance | null = null; let remoteGuacamoleApi: AxiosInstance | null = null; export function getRemoteFileManagerApi(): AxiosInstance { @@ -851,11 +852,25 @@ export function getRemoteTunnelApi(): AxiosInstance { export function getRemoteStatsApi(): AxiosInstance { if (!remoteStatsApi) { - remoteStatsApi = createRemoteOriginApiInstance(""); + remoteStatsApi = createRemoteOriginApiInstance("/plugin-api/host-metrics"); } 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 { if (!remoteGuacamoleApi) { remoteGuacamoleApi = createRemoteOriginApiInstance(""); @@ -917,8 +932,13 @@ function initializeApiInstances() { "FILE_MANAGER", ); - // Server Statistics API (port 30005) - statsApi = createApiInstance(getApiUrl("", 30005), "STATS"); + // Host Metrics API - the host-metrics plugin, mounted on the main backend. + // 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) authApi = createApiInstance(getApiUrl("", 30001), "AUTH"); @@ -929,8 +949,11 @@ function initializeApiInstances() { // RBAC API (port 30001) rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); - // Docker Management API (port 30007) - dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER"); + // Docker Management API - the docker plugin, mounted on the main backend. + dockerApi = createApiInstance( + getApiUrl("/plugin-api/docker/docker", 30001), + "DOCKER", + ); // Tmux Monitor API (port 30010) --- tmux-monitor --- tmuxMonitorApi = createApiInstance( @@ -953,7 +976,7 @@ export let tunnelApi: AxiosInstance; // File Manager Operations API (port 30004) export let fileManagerApi: AxiosInstance; -// Server Statistics API (port 30005) +// Host Metrics API (host-metrics plugin) export let statsApi: AxiosInstance; // Authentication API (port 30001) @@ -965,7 +988,7 @@ export let dashboardApi: AxiosInstance; // RBAC API (port 30001) export let rbacApi: AxiosInstance; -// Docker Management API (port 30007) +// Docker Management API (docker plugin) export let dockerApi: AxiosInstance; // Tmux Monitor API (port 30010) --- tmux-monitor --- diff --git a/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx b/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx index 95973f739..b6674a475 100644 --- a/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx +++ b/src/ui/tests/features/session-sharing/SharedSessionView.test.tsx @@ -114,7 +114,7 @@ describe("SharedSessionView", () => { api.resolveShareLink.mockResolvedValue({ protocol: "rdp", permissionLevel: "read-only", - wsPath: "/guacamole/websocket/", + wsPath: "/plugin-ws/remote-desktop/display", connectParams: { token: "guac-join-token" }, }); diff --git a/src/ui/tests/lib/connection-origin.test.ts b/src/ui/tests/lib/connection-origin.test.ts index b9e2dc3c8..278c793ee 100644 --- a/src/ui/tests/lib/connection-origin.test.ts +++ b/src/ui/tests/lib/connection-origin.test.ts @@ -127,14 +127,15 @@ describe("resolveConnectionOrigin", () => { }); /** - * The embedded backend authenticates a local WebSocket from `?token=`, because - * the browser WebSocket API cannot set an Authorization header. Electron's main - * process does inject a JWT cookie, but only on an exact origin match, and the - * remembered cookie belongs to the API origin (`localhost:30001`) — so nothing - * is attached to a `127.0.0.1:30009` connection. + * The embedded backend authenticates a local WebSocket from its subprotocol, + * because the browser WebSocket API cannot set an Authorization header. + * Electron's main process does inject a JWT cookie, but only on an exact + * origin match, so a socket has to carry the credential itself. * - * The Docker console opted out of the query token and had no other credential - * left, so its handshake was closed with 1008 while logs and stats kept working. + * This caught a real bug: the Docker console opted out of the token and had no + * 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", () => { const store: Record = {}; @@ -154,13 +155,13 @@ describe("buildOriginWsUrl", () => { // Every interactive channel on the embedded backend relies on this. const target = await buildOriginWsUrl({ origin: "local", - localPort: 30009, - localPath: "/docker/console/", - remotePath: "/docker/console/", + localPort: 30001, + localPath: "/plugin-ws/docker/console", + remotePath: "/plugin-ws/docker/console", }); 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"], }); }); @@ -168,14 +169,14 @@ describe("buildOriginWsUrl", () => { it("omits it only when a caller asks", async () => { const target = await buildOriginWsUrl({ origin: "local", - localPort: 30009, - localPath: "/docker/console/", - remotePath: "/docker/console/", + localPort: 30001, + localPath: "/plugin-ws/docker/console", + remotePath: "/plugin-ws/docker/console", includeJwt: false, }); expect(target).toEqual({ - url: "ws://127.0.0.1:30009/docker/console/", + url: "ws://127.0.0.1:30001/plugin-ws/docker/console", protocols: [], }); }); @@ -193,14 +194,14 @@ describe("buildOriginWsUrl", () => { const target = await buildOriginWsUrl({ origin: "remote", - localPort: 30008, - localPath: "/guacamole/websocket/", - remotePath: "/guacamole/websocket/", + localPort: 30001, + localPath: "/plugin-ws/remote-desktop/display", + remotePath: "/plugin-ws/remote-desktop/display", includeJwt: false, }); expect(target).toEqual({ - url: "wss://termix.example/guacamole/websocket/", + url: "wss://termix.example/plugin-ws/remote-desktop/display", protocols: [], }); }); @@ -210,13 +211,13 @@ describe("buildOriginWsUrl", () => { const target = await buildOriginWsUrl({ origin: "local", - localPort: 30002, - localPath: "", - remotePath: "/ssh/websocket/", + localPort: 30001, + localPath: "/plugin-ws/ssh-terminal/terminal", + remotePath: "/plugin-ws/ssh-terminal/terminal", }); expect(target).toEqual({ - url: "ws://127.0.0.1:30002", + url: "ws://127.0.0.1:30001/plugin-ws/ssh-terminal/terminal", protocols: [], }); }); diff --git a/src/ui/tests/lib/remote-server-api.test.ts b/src/ui/tests/lib/remote-server-api.test.ts index a81cdec0d..4a633e787 100644 --- a/src/ui/tests/lib/remote-server-api.test.ts +++ b/src/ui/tests/lib/remote-server-api.test.ts @@ -5,7 +5,7 @@ const isElectron = vi.hoisted(() => vi.fn()); const remoteApi = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@/lib/electron", () => ({ isElectron })); -vi.mock("@/main-axios", () => ({ getRemoteStatsApi: () => remoteApi })); +vi.mock("@/main-axios", () => ({ getRemoteCoreApi: () => remoteApi })); import { getConnectedRemoteApi, diff --git a/vite.config.ts b/vite.config.ts index defefb4d5..82178f123 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,10 +10,10 @@ const sslKeyPath = path.join(process.cwd(), "ssl/termix.key"); const hasSSL = fs.existsSync(sslCertPath) && fs.existsSync(sslKeyPath); const useHTTPS = process.env.VITE_HTTPS === "true" && hasSSL; -const apiProxyPorts = [ - 30001, 30002, 30003, 30004, 30005, 30006, 30007, 30008, 30009, 30010, 30011, - 30012, -]; +// Plugins no longer own ports: their HTTP and WebSocket traffic rides 30001 +// under /plugin-api and /plugin-ws. What is left is core servers that have not +// moved yet. +const apiProxyPorts = [30001, 30003, 30004, 30006, 30010, 30011, 30012]; const apiProxy = Object.fromEntries( apiProxyPorts.map((port) => [ `/__termix_api/${port}`,