mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e8ce6df7 | ||
|
|
3517b2f0ed | ||
|
|
220f5c8676 | ||
|
|
4dfad3efb3 | ||
|
|
e152bacc7c | ||
|
|
8d9c83b0f6 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -143,12 +143,12 @@ jobs:
|
|||||||
- name: Build Windows (All Architectures)
|
- name: Build Windows (All Architectures)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
|
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
|
||||||
|
|
||||||
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
|
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
|
||||||
# The combined x64+ia32 installer is never uploaded.
|
# The combined multi-arch installer is never uploaded.
|
||||||
- name: Fix Windows update files
|
- name: Fix Windows update files
|
||||||
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
|
||||||
|
|
||||||
- name: Upload Windows x64 NSIS Installer
|
- name: Upload Windows x64 NSIS Installer
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -166,6 +166,14 @@ jobs:
|
|||||||
path: release/termix_windows_ia32_nsis.exe
|
path: release/termix_windows_ia32_nsis.exe
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 NSIS Installer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_arm64_nsis
|
||||||
|
path: release/termix_windows_arm64_nsis.exe
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload Windows x64 MSI Installer
|
- name: Upload Windows x64 MSI Installer
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||||
@@ -182,6 +190,14 @@ jobs:
|
|||||||
path: release/termix_windows_ia32_msi.msi
|
path: release/termix_windows_ia32_msi.msi
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 MSI Installer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_arm64_msi
|
||||||
|
path: release/termix_windows_arm64_msi.msi
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
- name: Create Windows x64 Portable zip
|
- name: Create Windows x64 Portable zip
|
||||||
if: hashFiles('release/win-unpacked/*') != ''
|
if: hashFiles('release/win-unpacked/*') != ''
|
||||||
run: |
|
run: |
|
||||||
@@ -192,6 +208,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
||||||
|
|
||||||
|
- name: Create Windows arm64 Portable zip
|
||||||
|
if: hashFiles('release/win-arm64-unpacked/*') != ''
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
|
||||||
|
|
||||||
- name: Upload Windows update files
|
- name: Upload Windows update files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
|
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
|
||||||
@@ -219,6 +240,14 @@ jobs:
|
|||||||
path: termix_windows_ia32_portable.zip
|
path: termix_windows_ia32_portable.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 Portable
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_arm64_portable
|
||||||
|
path: termix_windows_arm64_portable.zip
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||||
|
|||||||
@@ -6,10 +6,16 @@
|
|||||||
|
|
||||||
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
|
||||||
- Sort the dashboard's Host Status card by name, IP address or status
|
- Sort the dashboard's Host Status card by name, IP address or status
|
||||||
|
- Windows on ARM builds of the desktop app (installer, MSI and portable)
|
||||||
|
- Plugins get an event when a host is added
|
||||||
|
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
|
||||||
|
- Plugins can pin a panel to the right edge of the window that pushes the page over
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
|
||||||
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
|
||||||
|
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -34,6 +40,13 @@
|
|||||||
- nginx answered 404 instead of 502 while the backend was down
|
- nginx answered 404 instead of 502 while the backend was down
|
||||||
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
|
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
|
||||||
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
|
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
|
||||||
|
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
|
||||||
|
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
|
||||||
|
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
|
||||||
|
- A linked desktop could never finish syncing with a server on Postgres
|
||||||
|
- Routine plugin activity filled the audit log and pushed out real security entries
|
||||||
|
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
|
||||||
|
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
|
||||||
|
|
||||||
## 26.10.1
|
## 26.10.1
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
@@ -135,7 +137,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
|||||||
|
|
||||||
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
See the [docs](https://docs.termix.site/install) for install steps on every platform.
|
||||||
|
|
||||||
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
|
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -149,31 +151,10 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
GUACD_HOST: "guacd"
|
|
||||||
GUACD_TUNNEL_HOST: "termix"
|
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
|
||||||
depends_on:
|
|
||||||
- guacd
|
|
||||||
networks:
|
|
||||||
- termix-net
|
|
||||||
|
|
||||||
guacd:
|
|
||||||
image: guacamole/guacd:1.6.0
|
|
||||||
container_name: guacd
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- termix-data:/termix-data
|
|
||||||
networks:
|
|
||||||
- termix-net
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
|
||||||
termix-net:
|
|
||||||
driver: bridge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cloud Hosting
|
### Cloud Hosting
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ COPY . .
|
|||||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||||
|
|
||||||
RUN npm cache clean --force && \
|
RUN npm cache clean --force && \
|
||||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||||
|
|
||||||
# Stage 3: Build backend
|
# Stage 3: Build backend
|
||||||
FROM deps AS backend-builder
|
FROM deps AS backend-builder
|
||||||
|
|||||||
+68
-68
@@ -3,20 +3,20 @@
|
|||||||
{
|
{
|
||||||
"id": "acme-ssl",
|
"id": "acme-ssl",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||||
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
|
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "ai",
|
"id": "ai",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||||
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
|
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "alerts",
|
"id": "alerts",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||||
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
|
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -25,14 +25,14 @@
|
|||||||
{
|
{
|
||||||
"id": "automations",
|
"id": "automations",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||||
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
|
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||||
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
|
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
{
|
{
|
||||||
"id": "file-manager",
|
"id": "file-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||||
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
|
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -51,20 +51,20 @@
|
|||||||
{
|
{
|
||||||
"id": "fleets",
|
"id": "fleets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||||
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
|
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "homepage",
|
"id": "homepage",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||||
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
|
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "host-metrics",
|
"id": "host-metrics",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||||
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
|
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -73,32 +73,32 @@
|
|||||||
{
|
{
|
||||||
"id": "ldap",
|
"id": "ldap",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||||
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
|
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "network-topology",
|
"id": "network-topology",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||||
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
|
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "opkssh",
|
"id": "opkssh",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||||
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
|
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "proxmox",
|
"id": "proxmox",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||||
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
|
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remote-desktop",
|
"id": "remote-desktop",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||||
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
|
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -107,14 +107,14 @@
|
|||||||
{
|
{
|
||||||
"id": "secret-sources",
|
"id": "secret-sources",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||||
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
|
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "serial",
|
"id": "serial",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||||
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
|
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": "desktop",
|
"recommended": "desktop",
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -123,20 +123,20 @@
|
|||||||
{
|
{
|
||||||
"id": "session-recording",
|
"id": "session-recording",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||||
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
|
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "session-sharing",
|
"id": "session-sharing",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||||
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
|
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "snippets",
|
"id": "snippets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||||
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
|
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -145,8 +145,8 @@
|
|||||||
{
|
{
|
||||||
"id": "ssh-terminal",
|
"id": "ssh-terminal",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||||
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
|
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -155,8 +155,8 @@
|
|||||||
{
|
{
|
||||||
"id": "sso",
|
"id": "sso",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||||
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
|
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": false,
|
"recommended": false,
|
||||||
"consent": false,
|
"consent": false,
|
||||||
@@ -166,20 +166,20 @@
|
|||||||
{
|
{
|
||||||
"id": "step-ca",
|
"id": "step-ca",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||||
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
|
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tailscale",
|
"id": "tailscale",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||||
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
|
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "telemetry",
|
"id": "telemetry",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||||
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
|
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": true
|
"consent": true
|
||||||
@@ -188,20 +188,20 @@
|
|||||||
{
|
{
|
||||||
"id": "termix-identity",
|
"id": "termix-identity",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||||
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
|
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tmux-monitor",
|
"id": "tmux-monitor",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||||
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
|
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "totp",
|
"id": "totp",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||||
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
|
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -210,8 +210,8 @@
|
|||||||
{
|
{
|
||||||
"id": "tunnels",
|
"id": "tunnels",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||||
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
|
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -220,32 +220,32 @@
|
|||||||
{
|
{
|
||||||
"id": "vault",
|
"id": "vault",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||||
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
|
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wake-on-lan",
|
"id": "wake-on-lan",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||||
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
|
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "warpgate",
|
"id": "warpgate",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||||
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
|
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "web-endpoint",
|
"id": "web-endpoint",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||||
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
|
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "webauthn",
|
"id": "webauthn",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||||
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
|
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
@@ -254,8 +254,8 @@
|
|||||||
{
|
{
|
||||||
"id": "workspaces",
|
"id": "workspaces",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||||
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
|
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"consent": false
|
"consent": false
|
||||||
|
|||||||
@@ -12,24 +12,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
GUACD_HOST: "guacd-dev"
|
|
||||||
GUACD_TUNNEL_HOST: "termix-dev"
|
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
|
||||||
# REDIS_URL: "redis://redis:6379"
|
# REDIS_URL: "redis://redis:6379"
|
||||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||||
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||||
depends_on:
|
|
||||||
- guacd-dev
|
|
||||||
networks:
|
|
||||||
- termix-dev-net
|
|
||||||
|
|
||||||
guacd-dev:
|
|
||||||
image: guacamole/guacd:1.6.0
|
|
||||||
container_name: guacd-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- termix-dev-data:/termix-data
|
|
||||||
networks:
|
networks:
|
||||||
- termix-dev-net
|
- termix-dev-net
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
GUACD_HOST: "guacd"
|
|
||||||
GUACD_TUNNEL_HOST: "termix"
|
|
||||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
|
||||||
# Where guacd stores files for RDP drive redirection (one folder per
|
|
||||||
# user is created underneath). Must be writable by guacd's user.
|
|
||||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
|
||||||
# Multi-instance deployments should point every backend at the same
|
# Multi-instance deployments should point every backend at the same
|
||||||
# Redis. Without it, collaboration rooms keep their single-instance
|
# Redis. Without it, collaboration rooms keep their single-instance
|
||||||
# in-memory fallback.
|
# in-memory fallback.
|
||||||
@@ -36,17 +30,6 @@ services:
|
|||||||
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
|
||||||
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
|
||||||
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
|
||||||
depends_on:
|
|
||||||
- guacd
|
|
||||||
networks:
|
|
||||||
- termix-net
|
|
||||||
|
|
||||||
guacd:
|
|
||||||
image: guacamole/guacd:1.6.0
|
|
||||||
container_name: guacd
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- termix-data:/termix-data
|
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
|
|||||||
@@ -46,11 +46,11 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"arch": ["x64", "ia32"]
|
"arch": ["x64", "ia32", "arm64"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "msi",
|
"target": "msi",
|
||||||
"arch": ["x64", "ia32"]
|
"arch": ["x64", "ia32", "arm64"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico",
|
"icon": "public/icon.ico",
|
||||||
|
|||||||
@@ -6,9 +6,16 @@
|
|||||||
|
|
||||||
let linked = null;
|
let linked = null;
|
||||||
|
|
||||||
|
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
|
||||||
|
|
||||||
|
// A WebSocket to the linked server (wss://host/...) counts as the link's
|
||||||
|
// https://host origin, so its upgrade carries the proxy headers too.
|
||||||
function getOrigin(url) {
|
function getOrigin(url) {
|
||||||
try {
|
try {
|
||||||
return new URL(url).origin;
|
const parsed = new URL(url);
|
||||||
|
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
|
||||||
|
if (httpProtocol) parsed.protocol = httpProtocol;
|
||||||
|
return parsed.origin;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|||||||
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
|
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
|
||||||
// which keeps every transfer on the backend's plugin routes.
|
// which keeps every transfer on the backend's plugin routes.
|
||||||
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
|
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
|
||||||
const LOCAL_BACKEND = "http://localhost:30001";
|
let localBackendPort = 30001;
|
||||||
|
|
||||||
|
function setLocalBackendPort(port) {
|
||||||
|
localBackendPort = port;
|
||||||
|
}
|
||||||
let registeredApiPath = null;
|
let registeredApiPath = null;
|
||||||
|
|
||||||
function setTransferApiPath(apiPath) {
|
function setTransferApiPath(apiPath) {
|
||||||
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
url: `${normalizeHttpBase(
|
url: `${normalizeHttpBase(
|
||||||
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
|
localBaseUrl ??
|
||||||
|
`http://localhost:${localBackendPort}${resolveApiPath()}`,
|
||||||
"Local backend URL",
|
"Local backend URL",
|
||||||
)}${routePath}`,
|
)}${routePath}`,
|
||||||
headers,
|
headers,
|
||||||
@@ -1050,6 +1055,7 @@ module.exports = {
|
|||||||
createLocalFileHandlers,
|
createLocalFileHandlers,
|
||||||
createTargetResolver,
|
createTargetResolver,
|
||||||
setTransferApiPath,
|
setTransferApiPath,
|
||||||
|
setLocalBackendPort,
|
||||||
publishDownload,
|
publishDownload,
|
||||||
defaultPublishFs,
|
defaultPublishFs,
|
||||||
assertWithinRoot,
|
assertWithinRoot,
|
||||||
|
|||||||
+39
-11
@@ -35,7 +35,10 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
|||||||
const { quitApp } = require("./app-quit.cjs");
|
const { quitApp } = require("./app-quit.cjs");
|
||||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||||
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
|
||||||
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
const {
|
||||||
|
registerLocalFileHandlers,
|
||||||
|
setLocalBackendPort,
|
||||||
|
} = require("./local-files.cjs");
|
||||||
const {
|
const {
|
||||||
EXTERNAL_EDITOR_SETTING,
|
EXTERNAL_EDITOR_SETTING,
|
||||||
isLaunchableFileName,
|
isLaunchableFileName,
|
||||||
@@ -439,14 +442,6 @@ function isInsecureModeEnabled() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrigin(url) {
|
|
||||||
try {
|
|
||||||
return new URL(url).origin;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrivateNetworkHost(hostname) {
|
function isPrivateNetworkHost(hostname) {
|
||||||
if (
|
if (
|
||||||
hostname === "localhost" ||
|
hostname === "localhost" ||
|
||||||
@@ -490,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
|
|||||||
// allows a self-signed certificate.
|
// allows a self-signed certificate.
|
||||||
const linked = linkedServer.getLinkedServer();
|
const linked = linkedServer.getLinkedServer();
|
||||||
if (!linked?.allowInvalidCertificate) return false;
|
if (!linked?.allowInvalidCertificate) return false;
|
||||||
return getOrigin(url) === linked.origin;
|
return linkedServer.isLinkedOrigin(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTlsVerificationOptions(url) {
|
function getTlsVerificationOptions(url) {
|
||||||
@@ -1124,6 +1119,35 @@ function reapOrphanedBackendProcess() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BACKEND_PORT = 30001;
|
||||||
|
let backendPort = DEFAULT_BACKEND_PORT;
|
||||||
|
|
||||||
|
function canListenOn(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.once("error", () => resolve(null));
|
||||||
|
probe.listen(port, "127.0.0.1", () => {
|
||||||
|
const { port: bound } = probe.address();
|
||||||
|
probe.close(() => resolve(bound));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30001 when it is free, otherwise any free port. Only the desktop app talks
|
||||||
|
// to this backend, so the number itself does not matter.
|
||||||
|
async function findBackendPort() {
|
||||||
|
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
const fallback = await canListenOn(0);
|
||||||
|
if (fallback) {
|
||||||
|
logToFile(
|
||||||
|
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return DEFAULT_BACKEND_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
function startBackendServer() {
|
function startBackendServer() {
|
||||||
reapOrphanedBackendProcess();
|
reapOrphanedBackendProcess();
|
||||||
backendFailure = null;
|
backendFailure = null;
|
||||||
@@ -1164,7 +1188,8 @@ function startBackendServer() {
|
|||||||
DATA_DIR: dataDir,
|
DATA_DIR: dataDir,
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
ELECTRON_EMBEDDED: "true",
|
ELECTRON_EMBEDDED: "true",
|
||||||
PORT: "30001",
|
PORT: String(backendPort),
|
||||||
|
EMBEDDED_BACKEND_PORT: String(backendPort),
|
||||||
VERSION: app.getVersion(),
|
VERSION: app.getVersion(),
|
||||||
},
|
},
|
||||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||||
@@ -1429,6 +1454,7 @@ function createWindow() {
|
|||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
|
additionalArguments: [`--termix-backend-port=${backendPort}`],
|
||||||
partition: termixSessionPartition,
|
partition: termixSessionPartition,
|
||||||
allowRunningInsecureContent: false,
|
allowRunningInsecureContent: false,
|
||||||
webviewTag: false,
|
webviewTag: false,
|
||||||
@@ -3814,6 +3840,8 @@ app.whenReady().then(async () => {
|
|||||||
await clearElectronJwtCookiesAtStartup();
|
await clearElectronJwtCookiesAtStartup();
|
||||||
|
|
||||||
if (!isDev) {
|
if (!isDev) {
|
||||||
|
backendPort = await findBackendPort();
|
||||||
|
setLocalBackendPort(backendPort);
|
||||||
const result = await startBackendServer();
|
const result = await startBackendServer();
|
||||||
logToFile("startBackendServer result:", result);
|
logToFile("startBackendServer result:", result);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
|
|||||||
return ipcRenderer.invoke(channel, ...args);
|
return ipcRenderer.invoke(channel, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The embedded backend's port, picked by the main process at startup.
|
||||||
|
const backendPortArg = process.argv.find((arg) =>
|
||||||
|
arg.startsWith("--termix-backend-port="),
|
||||||
|
);
|
||||||
|
const backendPort = backendPortArg
|
||||||
|
? Number(backendPortArg.split("=")[1])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
backendPort,
|
||||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||||
// Kept so the interface size can reset a zoom an earlier build applied.
|
// Kept so the interface size can reset a zoom an earlier build applied.
|
||||||
setZoomFactor: (factor) => {
|
setZoomFactor: (factor) => {
|
||||||
|
|||||||
+6
-10
@@ -71,7 +71,6 @@
|
|||||||
"group": "server",
|
"group": "server",
|
||||||
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
|
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "ENABLE_SSL",
|
"name": "ENABLE_SSL",
|
||||||
"group": "https",
|
"group": "https",
|
||||||
@@ -108,7 +107,6 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
"description": "Set by the image when nginx serves HTTPS in front of the backend."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "DATABASE_DIALECT",
|
"name": "DATABASE_DIALECT",
|
||||||
"group": "database",
|
"group": "database",
|
||||||
@@ -161,7 +159,6 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Where database migrations are read from. Set by the image."
|
"description": "Where database migrations are read from. Set by the image."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "JWT_SECRET",
|
"name": "JWT_SECRET",
|
||||||
"group": "secrets",
|
"group": "secrets",
|
||||||
@@ -204,7 +201,6 @@
|
|||||||
"secret": true,
|
"secret": true,
|
||||||
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "ALLOW_PASSWORD_LOGIN",
|
"name": "ALLOW_PASSWORD_LOGIN",
|
||||||
"group": "auth",
|
"group": "auth",
|
||||||
@@ -283,7 +279,6 @@
|
|||||||
"group": "auth",
|
"group": "auth",
|
||||||
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
|
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "TRUSTED_PROXIES",
|
"name": "TRUSTED_PROXIES",
|
||||||
"group": "proxy",
|
"group": "proxy",
|
||||||
@@ -315,7 +310,6 @@
|
|||||||
"group": "proxy",
|
"group": "proxy",
|
||||||
"description": "Comma separated hosts that skip the proxy."
|
"description": "Comma separated hosts that skip the proxy."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "LOG_LEVEL",
|
"name": "LOG_LEVEL",
|
||||||
"group": "logging",
|
"group": "logging",
|
||||||
@@ -355,7 +349,6 @@
|
|||||||
"deprecated": "the session-recording plugin setting",
|
"deprecated": "the session-recording plugin setting",
|
||||||
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
|
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "TERMIX_PLUGIN_REGISTRY_URL",
|
"name": "TERMIX_PLUGIN_REGISTRY_URL",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
@@ -406,7 +399,6 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by npm run dev."
|
"description": "Set by npm run dev."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "ENABLE_INSECURE_MODE",
|
"name": "ENABLE_INSECURE_MODE",
|
||||||
"group": "desktop",
|
"group": "desktop",
|
||||||
@@ -435,6 +427,12 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by the desktop app when it runs its own backend."
|
"description": "Set by the desktop app when it runs its own backend."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "EMBEDDED_BACKEND_PORT",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "TERMIX_DATA_DIR",
|
"name": "TERMIX_DATA_DIR",
|
||||||
"group": "desktop",
|
"group": "desktop",
|
||||||
@@ -459,14 +457,12 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
|
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "DESKTOP_SESSION",
|
"name": "DESKTOP_SESSION",
|
||||||
"group": "desktop",
|
"group": "desktop",
|
||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Read on Linux to pick the password store."
|
"description": "Read on Linux to pick the password store."
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "VITE_BASE_PATH",
|
"name": "VITE_BASE_PATH",
|
||||||
"group": "internal",
|
"group": "internal",
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.1",
|
"version": "26.10.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.1",
|
"version": "26.10.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.8",
|
"version": "1.0.11",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.1",
|
"version": "26.10.2",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"dev:electron": "node scripts/dev.mjs electron",
|
"dev:electron": "node scripts/dev.mjs electron",
|
||||||
"dev:docker": "node scripts/dev.mjs docker",
|
"dev:docker": "node scripts/dev.mjs docker",
|
||||||
"dev:frontend": "vite",
|
"dev:frontend": "vite",
|
||||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||||
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.11
|
||||||
|
|
||||||
|
Plugin API 1.3.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
|
||||||
|
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
|
||||||
|
|
||||||
|
## 1.0.10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
|
||||||
|
|
||||||
|
## 1.0.9
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
|
||||||
|
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
|
||||||
|
|
||||||
## 1.0.8
|
## 1.0.8
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.8 | Plugin API 1.2.0
|
SDK 1.0.11 | Plugin API 1.3.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
|
|||||||
npm run typecheck # tsc
|
npm run typecheck # tsc
|
||||||
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
|
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
|
||||||
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
|
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
|
||||||
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
|
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
|
||||||
npm run format # Prettier
|
npm run format # Prettier
|
||||||
npm run format:check
|
npm run format:check
|
||||||
```
|
```
|
||||||
@@ -105,7 +105,7 @@ Before you call a change finished:
|
|||||||
- [ ] `npm run build` passes
|
- [ ] `npm run build` passes
|
||||||
- [ ] `npm run format:check` passes
|
- [ ] `npm run format:check` passes
|
||||||
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
|
||||||
- [ ] New strings are in `locales/en.json`
|
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
|
||||||
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
- [ ] New routes have an `@openapi` block and a test, including without the permission
|
||||||
- [ ] New env vars are listed in `env` in the manifest
|
- [ ] New env vars are listed in `env` in the manifest
|
||||||
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
|
||||||
@@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
|||||||
| `audit.record` | none | Write your own audit line |
|
| `audit.record` | none | Write your own audit line |
|
||||||
| `sync` | none | Sync your tables to a linked desktop |
|
| `sync` | none | Sync your tables to a linked desktop |
|
||||||
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
|
||||||
|
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
|
||||||
|
| `users.getLocale` | `users:read` | A user's UI language |
|
||||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||||
@@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
|
|||||||
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
|
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
|
||||||
that user's permissions.
|
that user's permissions.
|
||||||
|
|
||||||
|
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
|
||||||
|
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
|
||||||
|
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
|
||||||
|
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
|
||||||
|
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
|
||||||
|
UI language, like `zh_CN`, or null, for writing to them in it.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
|
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
|
||||||
@@ -334,8 +343,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
|||||||
### Shared packages
|
### Shared packages
|
||||||
|
|
||||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||||
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
|
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
|
||||||
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
|
`peerDependencies`. Any other npm package is bundled.
|
||||||
|
|
||||||
|
### Workers and WebAssembly
|
||||||
|
|
||||||
|
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
|
||||||
|
each worker entry in `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` bundles it alone into `dist/workers/engine.js`; start it with
|
||||||
|
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
|
||||||
|
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
|
||||||
|
`postMessage`.
|
||||||
|
|
||||||
|
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
|
||||||
|
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
|
||||||
|
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
|
||||||
|
to a `.d.ts` in the plugin.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
@@ -465,7 +493,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
|
|||||||
|
|
||||||
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
|
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
|
||||||
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
|
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
|
||||||
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
|
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||||
https://docs.termix.site/develop/more-extension-points.
|
https://docs.termix.site/develop/more-extension-points.
|
||||||
|
|
||||||
## Looking native
|
## Looking native
|
||||||
@@ -519,6 +547,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
|
|||||||
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
|
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
|
||||||
Untranslated strings show in English.
|
Untranslated strings show in English.
|
||||||
|
|
||||||
|
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
|
||||||
|
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
|
||||||
|
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
|
||||||
|
every language.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": {
|
||||||
|
"name": "Host Metrics",
|
||||||
|
"description": "Live CPU, memory, disk, network and GPU stats."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
|
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
|
||||||
@@ -683,20 +725,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
|
|
||||||
## termix-plugin CLI
|
## termix-plugin CLI
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||||
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` |
|
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
|
||||||
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
|
||||||
| `test` | Apply patches, run Vitest |
|
| `test` | Apply patches, run Vitest |
|
||||||
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
|
||||||
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
|
||||||
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
|
||||||
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
|
||||||
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
|
||||||
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
| `keygen` | Write a new signing key pair. `--out <dir>` |
|
||||||
| `changelog` | Print one version's notes. `--version x.y.z` |
|
| `changelog` | Print one version's notes. `--version x.y.z` |
|
||||||
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
|
||||||
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
|
||||||
|
|
||||||
## SDK entry points
|
## SDK entry points
|
||||||
|
|
||||||
@@ -721,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
| Capability | Risk | What it allows |
|
| Capability | Risk | What it allows |
|
||||||
| -------------------- | -------- | ------------------------------------------------------ |
|
| -------------------- | -------- | ------------------------------------------------------- |
|
||||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||||
| `process:spawn` | high | Run programs on the Termix server |
|
| `process:spawn` | high | Run programs on the Termix server |
|
||||||
| `users:write` | high | Add users and change their roles |
|
| `users:write` | high | Add users and change their roles |
|
||||||
| `users:impersonate` | high | Run background work as another user, audited |
|
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
| `device:serial` | high | Open serial and USB ports on the server |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||||
| `hosts:write` | medium | Add and edit hosts |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `credentials:write` | medium | Save new credentials |
|
||||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||||
| `network:serve` | medium | HTTP routes and sockets |
|
| `network:serve` | medium | HTTP routes and sockets |
|
||||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||||
| `users:read` | medium | Usernames and roles |
|
| `users:read` | medium | Usernames, roles and UI language |
|
||||||
| `events:core` | medium | Listen to core events |
|
| `events:core` | medium | Listen to core events |
|
||||||
| `notify:send` | medium | Send alerts |
|
| `notify:send` | medium | Send alerts |
|
||||||
| `audit:read` | medium | Read the audit log |
|
| `audit:read` | medium | Read the audit log |
|
||||||
| `desktop:window` | medium | Open windows in the desktop app |
|
| `desktop:window` | medium | Open windows in the desktop app |
|
||||||
| `hosts:read` | low | Read hosts the user can see |
|
| `hosts:read` | low | Read hosts the user can see |
|
||||||
| `db:own` | low | Your own tables |
|
| `db:own` | low | Your own tables |
|
||||||
| `kv:own` | low | Small key value state |
|
| `kv:own` | low | Small key value state |
|
||||||
| `files:own` | low | Your own folder on disk |
|
| `files:own` | low | Your own folder on disk |
|
||||||
| `secrets:own` | low | Your own encrypted secrets |
|
| `secrets:own` | low | Your own encrypted secrets |
|
||||||
| `settings:read-core` | low | Read Termix server settings |
|
| `settings:read-core` | low | Read Termix server settings |
|
||||||
| `plugins:read` | low | See installed plugins |
|
| `plugins:read` | low | See installed plugins |
|
||||||
| `ui:surface` | low | Add tabs, panels and settings |
|
| `ui:surface` | low | Add tabs, panels and settings |
|
||||||
|
|
||||||
An update that adds a capability waits for an admin to approve it.
|
An update that adds a capability waits for an admin to approve it.
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,43 @@ export function externalRequireInterop(externals) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
|
||||||
|
// so it ships in backend.js or a worker with no separate file to find.
|
||||||
|
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
|
||||||
|
const BINARY_LOADERS = Object.fromEntries(
|
||||||
|
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const REQUIRE_BANNER =
|
||||||
|
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
|
||||||
|
* bundles each entry on its own into dist/workers/<name>.js, for
|
||||||
|
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
|
||||||
|
* external: core's import fallback only runs on the main thread, so a worker
|
||||||
|
* can't rely on host packages and must not import the SDK.
|
||||||
|
*/
|
||||||
|
export function readWorkerEntries(cwd, pluginId) {
|
||||||
|
const pkgPath = path.join(cwd, "package.json");
|
||||||
|
const pkg = fs.existsSync(pkgPath)
|
||||||
|
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||||
|
: {};
|
||||||
|
const workers = pkg.termix?.workers ?? {};
|
||||||
|
return Object.entries(workers).map(([name, entry]) => {
|
||||||
|
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
|
||||||
|
}
|
||||||
|
const file = path.resolve(cwd, String(entry));
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw new Error(
|
||||||
|
`${pluginId}: worker "${name}" entry ${entry} is missing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { name, file };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const SDK_PATCHES = path.join(
|
const SDK_PATCHES = path.join(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
"..",
|
"..",
|
||||||
@@ -216,13 +253,27 @@ export async function build({ cwd }) {
|
|||||||
// node_modules import instead, resolved at runtime like a host-provided
|
// node_modules import instead, resolved at runtime like a host-provided
|
||||||
// package.
|
// package.
|
||||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||||
|
loader: BINARY_LOADERS,
|
||||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||||
banner: {
|
banner: { js: REQUIRE_BANNER },
|
||||||
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [worker.file],
|
||||||
|
outfile: path.join(outDir, "workers", `${worker.name}.js`),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "node",
|
||||||
|
target: "node22",
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: "warning",
|
||||||
|
loader: BINARY_LOADERS,
|
||||||
|
banner: { js: REQUIRE_BANNER },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Without this Node finds the host's typeless package.json, tries backend.js
|
// Without this Node finds the host's typeless package.json, tries backend.js
|
||||||
// as CommonJS first and warns before reparsing it as ESM.
|
// as CommonJS first and warns before reparsing it as ESM.
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
|
|||||||
"README.md",
|
"README.md",
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
"icon.svg",
|
"icon.svg",
|
||||||
|
// Licence texts, so third-party notices travel with the plugin.
|
||||||
|
"LICENSE",
|
||||||
|
"NOTICE",
|
||||||
];
|
];
|
||||||
|
|
||||||
const BLOCK = 512;
|
const BLOCK = 512;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.8",
|
"version": "1.0.11",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1464,6 +1464,20 @@ export interface PluginSecondFactor {
|
|||||||
reset?: (userId: string) => Promise<void>;
|
reset?: (userId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginUserTokenOptions {
|
||||||
|
/** Why the token exists, kept in the audit log and on the session. */
|
||||||
|
purpose: string;
|
||||||
|
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
|
||||||
|
ttlSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUserToken {
|
||||||
|
/** Send as `Authorization: Bearer <token>` to core's own API. */
|
||||||
|
token: string;
|
||||||
|
/** ISO time the session ends. */
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login methods, second factors and SSH auth types. Every register call needs
|
* Login methods, second factors and SSH auth types. Every register call needs
|
||||||
* auth:provide and a matching contributes.auth entry, and is undone on
|
* auth:provide and a matching contributes.auth entry, and is undone on
|
||||||
@@ -1525,6 +1539,26 @@ export interface PluginAuth {
|
|||||||
* delete a provider people still sign in with.
|
* delete a provider people still sign in with.
|
||||||
*/
|
*/
|
||||||
countLinkedUsers: (provider: string) => Promise<number>;
|
countLinkedUsers: (provider: string) => Promise<number>;
|
||||||
|
/**
|
||||||
|
* A short-lived session for an existing user, for calling core's own HTTP
|
||||||
|
* API as them from work with no request behind it, like a scheduled task
|
||||||
|
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
|
||||||
|
* users:impersonate, not auth:provide, and every call is audited.
|
||||||
|
*
|
||||||
|
* The session is marked with the plugin and purpose. It cannot change the
|
||||||
|
* user's password, second factors, API keys or linked logins, so it can
|
||||||
|
* never outlive itself.
|
||||||
|
*/
|
||||||
|
issueUserToken: (
|
||||||
|
userId: string,
|
||||||
|
options: PluginUserTokenOptions,
|
||||||
|
) => Promise<PluginUserToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a plugin may learn about a user. Needs users:read. */
|
||||||
|
export interface PluginUsers {
|
||||||
|
/** The user's UI language, like "zh_CN", or null when they never chose one. */
|
||||||
|
getLocale: (userId: string) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginOpenIsolatedWindowRequest {
|
export interface PluginOpenIsolatedWindowRequest {
|
||||||
@@ -2013,8 +2047,13 @@ export interface PluginContext {
|
|||||||
readonly hosts: PluginHosts;
|
readonly hosts: PluginHosts;
|
||||||
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
|
||||||
readonly ssh: PluginSsh;
|
readonly ssh: PluginSsh;
|
||||||
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
|
/**
|
||||||
|
* Login methods, second factors and SSH auth types. Needs auth:provide,
|
||||||
|
* except issueUserToken, which needs users:impersonate.
|
||||||
|
*/
|
||||||
readonly auth: PluginAuth;
|
readonly auth: PluginAuth;
|
||||||
|
/** What a plugin may learn about a user. Needs users:read. */
|
||||||
|
readonly users: PluginUsers;
|
||||||
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
||||||
readonly desktop: PluginDesktop;
|
readonly desktop: PluginDesktop;
|
||||||
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
||||||
@@ -2039,8 +2078,9 @@ export interface PluginContext {
|
|||||||
* no request behind it. Always audited, and every core API inside still
|
* no request behind it. Always audited, and every core API inside still
|
||||||
* applies that user's RBAC.
|
* applies that user's RBAC.
|
||||||
*
|
*
|
||||||
* Needs users:impersonate. That and a `userId` option on services.get or
|
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
|
||||||
* secrets.getShared are the only ways a plugin can name a user.
|
* on services.get or secrets.getShared are the only ways a plugin can act
|
||||||
|
* as a user it names.
|
||||||
*/
|
*/
|
||||||
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* version by the server.
|
||||||
*/
|
*/
|
||||||
export const PLUGIN_API_VERSION = "1.2.0";
|
export const PLUGIN_API_VERSION = "1.3.0";
|
||||||
|
|
||||||
/** The API major, the default engine.api for a new plugin. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
|
|||||||
linkedUsers?: Record<string, number>;
|
linkedUsers?: Record<string, number>;
|
||||||
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
|
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||||
|
locales?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakeProcessOptions {
|
export interface FakeProcessOptions {
|
||||||
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
|
|||||||
}>;
|
}>;
|
||||||
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
|
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
|
||||||
loginFailures: Map<string, number>;
|
loginFailures: Map<string, number>;
|
||||||
|
/** Every ctx.auth.issueUserToken call, in order. */
|
||||||
|
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
@@ -574,6 +578,7 @@ export function createFakeContext(
|
|||||||
completedLogins: [],
|
completedLogins: [],
|
||||||
revokedSessions: [],
|
revokedSessions: [],
|
||||||
loginFailures: new Map(),
|
loginFailures: new Map(),
|
||||||
|
userTokens: [],
|
||||||
};
|
};
|
||||||
const sshClient = options.sshClient ?? {};
|
const sshClient = options.sshClient ?? {};
|
||||||
let actor = options.actor;
|
let actor = options.actor;
|
||||||
@@ -1140,6 +1145,18 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
countLinkedUsers: async (provider) =>
|
countLinkedUsers: async (provider) =>
|
||||||
options.linkedUsers?.[provider] ?? 0,
|
options.linkedUsers?.[provider] ?? 0,
|
||||||
|
issueUserToken: async (userId, tokenOptions) => {
|
||||||
|
auth.userTokens.push({ userId, ...tokenOptions });
|
||||||
|
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
|
||||||
|
return {
|
||||||
|
token: `fake-token-${auth.userTokens.length}`,
|
||||||
|
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
users: {
|
||||||
|
getLocale: async (userId) => options.locales?.[userId] ?? null,
|
||||||
},
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
|
|||||||
linkedUsers?: Record<string, number>;
|
linkedUsers?: Record<string, number>;
|
||||||
/** See FakeContextOptions. */
|
/** See FakeContextOptions. */
|
||||||
loginAttemptLimit?: number;
|
loginAttemptLimit?: number;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
locales?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockPluginContext extends FakePluginContext {
|
export interface MockPluginContext extends FakePluginContext {
|
||||||
@@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext {
|
|||||||
* topic outside the plugin's own namespace, settings:read-core on
|
* topic outside the plugin's own namespace, settings:read-core on
|
||||||
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
|
||||||
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
|
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
|
||||||
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
|
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
|
||||||
|
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||||
*/
|
*/
|
||||||
export function createMockCtx(
|
export function createMockCtx(
|
||||||
@@ -1495,6 +1515,7 @@ export function createMockCtx(
|
|||||||
baseUrl: options.baseUrl,
|
baseUrl: options.baseUrl,
|
||||||
linkedUsers: options.linkedUsers,
|
linkedUsers: options.linkedUsers,
|
||||||
loginAttemptLimit: options.loginAttemptLimit,
|
loginAttemptLimit: options.loginAttemptLimit,
|
||||||
|
locales: options.locales,
|
||||||
manifest: {
|
manifest: {
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
...options.manifest,
|
...options.manifest,
|
||||||
@@ -1786,6 +1807,17 @@ export function createMockCtx(
|
|||||||
require("auth:provide");
|
require("auth:provide");
|
||||||
return ctx.auth.countLinkedUsers(provider);
|
return ctx.auth.countLinkedUsers(provider);
|
||||||
},
|
},
|
||||||
|
issueUserToken: async (userId, tokenOptions) => {
|
||||||
|
require("users:impersonate");
|
||||||
|
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
users: {
|
||||||
|
getLocale: async (userId) => {
|
||||||
|
require("users:read");
|
||||||
|
return ctx.users.getLocale(userId);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
|
||||||
|
* so code that imports a module compiles it the same way under vitest.
|
||||||
|
*/
|
||||||
|
export function wasmBinaryPlugin() {
|
||||||
|
return {
|
||||||
|
name: "termix-wasm-binary",
|
||||||
|
enforce: "pre" as const,
|
||||||
|
load(id: string) {
|
||||||
|
const file = id.split("?")[0];
|
||||||
|
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const base64 = fs.readFileSync(file).toString("base64");
|
||||||
|
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginVitestOptions {
|
export interface PluginVitestOptions {
|
||||||
/** Extra aliases merged over the defaults. */
|
/** Extra aliases merged over the defaults. */
|
||||||
alias?: Record<string, string>;
|
alias?: Record<string, string>;
|
||||||
@@ -96,7 +115,10 @@ export function pluginVitestConfig(
|
|||||||
// unexpectedly". Threads don't.
|
// unexpectedly". Threads don't.
|
||||||
const pool = "threads" as const;
|
const pool = "threads" as const;
|
||||||
|
|
||||||
|
const plugins = [wasmBinaryPlugin()];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
plugins,
|
||||||
resolve: { alias },
|
resolve: { alias },
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
@@ -104,6 +126,7 @@ export function pluginVitestConfig(
|
|||||||
pool,
|
pool,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
plugins,
|
||||||
resolve: { alias },
|
resolve: { alias },
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:backend`,
|
name: `${pluginId}:backend`,
|
||||||
@@ -116,6 +139,7 @@ export function pluginVitestConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
plugins,
|
||||||
resolve: { alias },
|
resolve: { alias },
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:frontend`,
|
name: `${pluginId}:frontend`,
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 47 KiB |
+6
-1
@@ -1,4 +1,9 @@
|
|||||||
const CACHE_NAME = "termix-static-v2";
|
// CACHE_NAME carries the package version so a new release always gets a fresh
|
||||||
|
// cache: the activate handler below deletes every cache whose name differs
|
||||||
|
// from this one, which is what evicts a previous build's entries. Both
|
||||||
|
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
|
||||||
|
// during `npm run build` - vite copies public/ verbatim and never transforms it.
|
||||||
|
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
|
||||||
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
|
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
`${BASE_PATH}/favicon.ico`,
|
`${BASE_PATH}/favicon.ico`,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
|
|||||||
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
|
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
|
||||||
GUACD_HOST: "127.0.0.1",
|
GUACD_HOST: "127.0.0.1",
|
||||||
GUACD_PORT: "4822",
|
GUACD_PORT: "4822",
|
||||||
|
REMOTE_DESKTOP_ENGINE: "guacd",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
|
|||||||
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
|
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
|
||||||
GUACD_HOST: "termix-dev-guacd",
|
GUACD_HOST: "termix-dev-guacd",
|
||||||
GUACD_PORT: "4822",
|
GUACD_PORT: "4822",
|
||||||
|
REMOTE_DESKTOP_ENGINE: "guacd",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
* mounted. A plugin change rebuilds it and restarts the container. Core
|
* mounted. A plugin change rebuilds it and restarts the container. Core
|
||||||
* changes wait for "r", since an image build is slow.
|
* changes wait for "r", since an image build is slow.
|
||||||
*
|
*
|
||||||
* --db postgres|mysql and --guacd run those in Docker next to it.
|
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
|
||||||
|
* switches remote desktop from its built-in engine to that guacd.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function start() {
|
|||||||
isDev: false,
|
isDev: false,
|
||||||
appRoot: "/app",
|
appRoot: "/app",
|
||||||
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
BACKEND_STDERR_TAIL_LIMIT: 4096,
|
||||||
|
backendPort: 30001,
|
||||||
handleBackendRequest,
|
handleBackendRequest,
|
||||||
setTimeout,
|
setTimeout,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
|
|||||||
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
||||||
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
||||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
|
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
|
||||||
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||||
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
|
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
|
||||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
|
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
|
||||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
|
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) {
|
|||||||
const guacd = SERVICES.guacd;
|
const guacd = SERVICES.guacd;
|
||||||
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
|
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
|
||||||
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
|
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
|
||||||
|
// Remote desktop runs built in unless told to use guacd.
|
||||||
|
env.REMOTE_DESKTOP_ENGINE = "guacd";
|
||||||
}
|
}
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ Options
|
|||||||
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
|
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
|
||||||
--no-local Use only the plugins pinned in docker/bundled-plugins.json
|
--no-local Use only the plugins pinned in docker/bundled-plugins.json
|
||||||
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
|
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
|
||||||
--guacd Run guacd in Docker for remote desktop
|
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
|
||||||
--fresh Start from empty data (first run, onboarding)
|
--fresh Start from empty data (first run, onboarding)
|
||||||
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
|
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
|
||||||
electron, plus -<engine> for postgres or mysql)
|
electron, plus -<engine> for postgres or mysql)
|
||||||
|
|||||||
@@ -92,6 +92,74 @@ describe("termix-plugin build", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin build workers and wasm", () => {
|
||||||
|
// The smallest valid module: magic and version, no sections.
|
||||||
|
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
|
||||||
|
|
||||||
|
it("inlines a .wasm import as its bytes", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const dir = fixturePlugin();
|
||||||
|
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src", "backend", "index.ts"),
|
||||||
|
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await build({ cwd: dir });
|
||||||
|
|
||||||
|
const mod = await import(
|
||||||
|
pathToFileURL(path.join(dir, "dist", "backend.js")).href
|
||||||
|
);
|
||||||
|
expect(mod.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bundles each worker entry on its own into dist/workers", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const dir = fixturePlugin();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
|
||||||
|
);
|
||||||
|
const dep = path.join(dir, "node_modules", "worker-dep");
|
||||||
|
fs.mkdirSync(dep, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dep, "package.json"),
|
||||||
|
JSON.stringify({ name: "worker-dep", main: "index.js" }),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "src", "worker.ts"),
|
||||||
|
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
await build({ cwd: dir });
|
||||||
|
|
||||||
|
const file = path.join(dir, "dist", "workers", "engine.js");
|
||||||
|
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
|
||||||
|
const { Worker } = await import("node:worker_threads");
|
||||||
|
const answer = await new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(file);
|
||||||
|
worker.once("message", (value) => {
|
||||||
|
void worker.terminate();
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
worker.once("error", reject);
|
||||||
|
});
|
||||||
|
expect(answer).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a worker whose entry is missing", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const dir = fixturePlugin();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("plugin CSS layers", () => {
|
describe("plugin CSS layers", () => {
|
||||||
it("renames the Tailwind layers so core's utilities always win", () => {
|
it("renames the Tailwind layers so core's utilities always win", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
|
|||||||
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
||||||
writeFile(dir, "README.md", "# Fixture\n");
|
writeFile(dir, "README.md", "# Fixture\n");
|
||||||
writeFile(dir, "icon.svg", "<svg/>\n");
|
writeFile(dir, "icon.svg", "<svg/>\n");
|
||||||
|
writeFile(dir, "LICENSE", "Apache-2.0\n");
|
||||||
|
writeFile(dir, "NOTICE", "Third-party notices\n");
|
||||||
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
|
|||||||
|
|
||||||
const names = await entries(file);
|
const names = await entries(file);
|
||||||
expect(names).toEqual([
|
expect(names).toEqual([
|
||||||
|
"LICENSE",
|
||||||
|
"NOTICE",
|
||||||
"README.md",
|
"README.md",
|
||||||
"dist/backend.js",
|
"dist/backend.js",
|
||||||
"dist/frontend.js",
|
"dist/frontend.js",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Stamp the cache version and static-build base path.
|
||||||
|
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, "..");
|
||||||
|
const SW_PATH = path.join(ROOT, "dist", "sw.js");
|
||||||
|
|
||||||
|
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
|
||||||
|
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
|
||||||
|
|
||||||
|
const pkg = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
|
||||||
|
|
||||||
|
const rawBasePath = process.env.VITE_BASE_PATH || "./";
|
||||||
|
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
|
||||||
|
const basePath =
|
||||||
|
rawBasePath === "./"
|
||||||
|
? ""
|
||||||
|
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
|
||||||
|
|
||||||
|
if (!fs.existsSync(SW_PATH)) {
|
||||||
|
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sw = fs.readFileSync(SW_PATH, "utf8");
|
||||||
|
const rel = path.relative(ROOT, SW_PATH);
|
||||||
|
const replacements = [
|
||||||
|
[VERSION_PLACEHOLDER, cacheVersion],
|
||||||
|
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
|
||||||
|
? []
|
||||||
|
: [[BASE_PATH_PLACEHOLDER, basePath]]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const [placeholder, value] of replacements) {
|
||||||
|
if (sw.includes(placeholder)) {
|
||||||
|
sw = sw.split(placeholder).join(value);
|
||||||
|
changed = true;
|
||||||
|
console.log(
|
||||||
|
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
fs.writeFileSync(SW_PATH, sw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, "", ""],
|
||||||
|
[false, "/termix/", "/termix"],
|
||||||
|
[true, "", "__TERMIX_SW_BASE_PATH__"],
|
||||||
|
])(
|
||||||
|
"stamps service worker paths (runtime=%s, base=%s)",
|
||||||
|
(runtime, base, expected) => {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
|
||||||
|
try {
|
||||||
|
mkdirSync(path.join(dir, "scripts"));
|
||||||
|
mkdirSync(path.join(dir, "dist"));
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
|
||||||
|
readFileSync("scripts/stamp-sw-build-info.cjs"),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(dir, "package.json"),
|
||||||
|
JSON.stringify({ version: "26.10.2" }),
|
||||||
|
);
|
||||||
|
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
|
||||||
|
execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VITE_BASE_PATH: base,
|
||||||
|
TERMIX_RUNTIME_BASE_PATH: String(runtime),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
|
||||||
|
expect(sw).toContain('"26-10-2"');
|
||||||
|
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
|
||||||
|
if (runtime) {
|
||||||
|
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
|
||||||
|
'const BASE_PATH = "/termix";',
|
||||||
|
);
|
||||||
|
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
|
||||||
|
"TERMIX_RUNTIME_BASE_PATH=true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
|
|||||||
import pluginRoutes from "./routes/plugins.js";
|
import pluginRoutes from "./routes/plugins.js";
|
||||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||||
import { getPluginRuntime } from "../plugins/index.js";
|
import { getPluginRuntime } from "../plugins/index.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -1388,6 +1389,9 @@ app.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||||
|
for (const hostId of importedHostIds) {
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||||
}
|
}
|
||||||
@@ -1716,7 +1720,8 @@ app.use(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const HTTP_PORT = 30001;
|
// The desktop app moves its embedded backend off 30001 when that is taken.
|
||||||
|
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
|
||||||
|
|
||||||
async function initializeSecurity() {
|
async function initializeSecurity() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
|||||||
* Tells status checks and other pollers about hosts an import wrote, the way
|
* Tells status checks and other pollers about hosts an import wrote, the way
|
||||||
* a single host create or update does.
|
* a single host create or update does.
|
||||||
*/
|
*/
|
||||||
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
|
export function notifyImportedHosts(
|
||||||
|
userId: string,
|
||||||
|
hostIds: number[],
|
||||||
|
createdHostIds: number[],
|
||||||
|
): void {
|
||||||
|
for (const hostId of createdHostIds) {
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||||
|
}
|
||||||
for (const hostId of hostIds) {
|
for (const hostId of hostIds) {
|
||||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||||
}
|
}
|
||||||
@@ -568,26 +575,26 @@ export function registerHostBulkRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
|
||||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||||
const hostRepository = createCurrentHostRepository();
|
const hostRepository = createCurrentHostRepository();
|
||||||
if (overwrite) {
|
try {
|
||||||
try {
|
const allHosts =
|
||||||
const allHosts =
|
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
userId,
|
||||||
userId,
|
);
|
||||||
);
|
existingHostMap = new Map();
|
||||||
existingHostMap = new Map();
|
for (const h of allHosts) {
|
||||||
for (const h of allHosts) {
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
existingHostMap.set(key, { id: h.id as number });
|
||||||
existingHostMap.set(key, { id: h.id as number });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
existingHostMap = undefined;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
existingHostMap = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownAuthTypes = listKnownAuthTypes();
|
const knownAuthTypes = listKnownAuthTypes();
|
||||||
const writtenHostIds: number[] = [];
|
const writtenHostIds: number[] = [];
|
||||||
|
const createdHostIds: number[] = [];
|
||||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||||
try {
|
try {
|
||||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||||
@@ -624,6 +631,15 @@ export function registerHostBulkRoutes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
if (existing && !overwrite) {
|
||||||
|
// Hosts that jump through this one still resolve to the kept host.
|
||||||
|
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
effectiveConnectionType === "ssh" &&
|
effectiveConnectionType === "ssh" &&
|
||||||
hostData.authType &&
|
hostData.authType &&
|
||||||
@@ -769,8 +785,6 @@ export function registerHostBulkRoutes(
|
|||||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
|
||||||
await withHostDefaults(
|
await withHostDefaults(
|
||||||
userId,
|
userId,
|
||||||
existing?.id ?? null,
|
existing?.id ?? null,
|
||||||
@@ -797,6 +811,7 @@ export function registerHostBulkRoutes(
|
|||||||
);
|
);
|
||||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||||
savedHostId = saved.id;
|
savedHostId = saved.id;
|
||||||
|
createdHostIds.push(saved.id);
|
||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,10 +843,10 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
notifyImportedHosts(userId, writtenHostIds);
|
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||||
success: results.success,
|
success: results.success,
|
||||||
updated: results.updated,
|
updated: results.updated,
|
||||||
skipped: results.skipped,
|
skipped: results.skipped,
|
||||||
@@ -931,23 +946,22 @@ export function registerHostBulkRoutes(
|
|||||||
|
|
||||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||||
const hostRepository = createCurrentHostRepository();
|
const hostRepository = createCurrentHostRepository();
|
||||||
if (overwrite) {
|
try {
|
||||||
try {
|
const allHosts =
|
||||||
const allHosts =
|
await createCurrentHostResolutionRepository().findHostsByUserId(
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
userId,
|
||||||
userId,
|
);
|
||||||
);
|
existingHostMap = new Map();
|
||||||
existingHostMap = new Map();
|
for (const h of allHosts) {
|
||||||
for (const h of allHosts) {
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
existingHostMap.set(key, { id: h.id as number });
|
||||||
existingHostMap.set(key, { id: h.id as number });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
existingHostMap = undefined;
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
existingHostMap = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const writtenHostIds: number[] = [];
|
const writtenHostIds: number[] = [];
|
||||||
|
const createdHostIds: number[] = [];
|
||||||
for (let i = 0; i < hostsToImport.length; i++) {
|
for (let i = 0; i < hostsToImport.length; i++) {
|
||||||
const hostData = normalizeImportedHost(
|
const hostData = normalizeImportedHost(
|
||||||
hostsToImport[i] as Record<string, unknown>,
|
hostsToImport[i] as Record<string, unknown>,
|
||||||
@@ -962,6 +976,13 @@ export function registerHostBulkRoutes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
if (existing && !overwrite) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
userId,
|
userId,
|
||||||
connectionType: "ssh",
|
connectionType: "ssh",
|
||||||
@@ -999,8 +1020,6 @@ export function registerHostBulkRoutes(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
|
||||||
await withHostDefaults(
|
await withHostDefaults(
|
||||||
userId,
|
userId,
|
||||||
existing?.id ?? null,
|
existing?.id ?? null,
|
||||||
@@ -1023,6 +1042,7 @@ export function registerHostBulkRoutes(
|
|||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
writtenHostIds.push(saved.id);
|
writtenHostIds.push(saved.id);
|
||||||
|
createdHostIds.push(saved.id);
|
||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1034,10 +1054,10 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
notifyImportedHosts(userId, writtenHostIds);
|
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||||
success: results.success,
|
success: results.success,
|
||||||
updated: results.updated,
|
updated: results.updated,
|
||||||
skipped: results.skipped,
|
skipped: results.skipped,
|
||||||
|
|||||||
@@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
|
|||||||
return authType === undefined || authType === null;
|
return authType === undefined || authType === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOptionalAuthType(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || typeof value === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth type a new SSH host's secrets point to when the body names none,
|
||||||
|
* as an API client creating a host from a credential does.
|
||||||
|
*/
|
||||||
|
export function impliedSshAuthType(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): string | undefined {
|
||||||
|
if (body.credentialId) return "credential";
|
||||||
|
if (body.key) return "key";
|
||||||
|
if (body.password) return "password";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const FOLDER_PATH_SEPARATOR = " / ";
|
const FOLDER_PATH_SEPARATOR = " / ";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyHostKeyTypeUpdate,
|
applyHostKeyTypeUpdate,
|
||||||
containsOwnerPrivateAuthUpdate,
|
containsOwnerPrivateAuthUpdate,
|
||||||
|
impliedSshAuthType,
|
||||||
keepsStoredSshAuth,
|
keepsStoredSshAuth,
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
|
isOptionalAuthType,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
@@ -259,7 +261,9 @@ router.post(
|
|||||||
!isNonEmptyString(ip) ||
|
!isNonEmptyString(ip) ||
|
||||||
!isValidPort(port) ||
|
!isValidPort(port) ||
|
||||||
!isOptionalBoolean(shareSshAuth) ||
|
!isOptionalBoolean(shareSshAuth) ||
|
||||||
!isOptionalBoolean(enableSsh)
|
!isOptionalBoolean(enableSsh) ||
|
||||||
|
!isOptionalAuthType(authType) ||
|
||||||
|
!isOptionalAuthType(authMethod)
|
||||||
) {
|
) {
|
||||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||||
operation: "host_create",
|
operation: "host_create",
|
||||||
@@ -292,7 +296,9 @@ router.post(
|
|||||||
const effectiveAuthType =
|
const effectiveAuthType =
|
||||||
authType ||
|
authType ||
|
||||||
authMethod ||
|
authMethod ||
|
||||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
(effectiveConnectionType !== "ssh"
|
||||||
|
? "password"
|
||||||
|
: impliedSshAuthType(hostData));
|
||||||
const effectiveUsername =
|
const effectiveUsername =
|
||||||
username || firstProtocolUsername(protocolAuthPatch);
|
username || firstProtocolUsername(protocolAuthPatch);
|
||||||
const effectiveName =
|
const effectiveName =
|
||||||
@@ -403,6 +409,13 @@ router.post(
|
|||||||
body: hostData,
|
body: hostData,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Host defaults may have supplied it.
|
||||||
|
if (!sshDataObj.authType) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"authType is required (password, key, credential, agent or none)",
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||||
userId,
|
userId,
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
@@ -473,6 +486,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
|
||||||
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sshLogger.error("Failed to save SSH host to database", err, {
|
sshLogger.error("Failed to save SSH host to database", err, {
|
||||||
@@ -827,6 +841,8 @@ router.put(
|
|||||||
!isValidPort(port) ||
|
!isValidPort(port) ||
|
||||||
!isOptionalBoolean(shareSshAuth) ||
|
!isOptionalBoolean(shareSshAuth) ||
|
||||||
!isOptionalBoolean(enableSsh) ||
|
!isOptionalBoolean(enableSsh) ||
|
||||||
|
!isOptionalAuthType(authType) ||
|
||||||
|
!isOptionalAuthType(authMethod) ||
|
||||||
!hostId
|
!hostId
|
||||||
) {
|
) {
|
||||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
|
|||||||
import { parseSshOptions } from "../hosts/ssh-options.js";
|
import { parseSshOptions } from "../hosts/ssh-options.js";
|
||||||
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||||
import type { DisposableBag } from "./disposables.js";
|
import type { DisposableBag } from "./disposables.js";
|
||||||
|
import { pluginEvents, TOPICS } from "./events.js";
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
type AuditFn = (
|
type AuditFn = (
|
||||||
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
await writeSettings(created.id, writes);
|
await writeSettings(created.id, writes);
|
||||||
await markSettingsOwn(created.id, writes);
|
await markSettingsOwn(created.id, writes);
|
||||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
|
||||||
return (
|
return (
|
||||||
await toRecords([created as unknown as Record<string, unknown>])
|
await toRecords([created as unknown as Record<string, unknown>])
|
||||||
)[0];
|
)[0];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { getActor, getActorSessionId } from "./actor.js";
|
import { getActor, getActorSessionId } from "./actor.js";
|
||||||
import type { DisposableBag } from "./disposables.js";
|
import type { DisposableBag } from "./disposables.js";
|
||||||
import { redactHostSecrets } from "./ctx-hosts.js";
|
import { redactHostSecrets } from "./ctx-hosts.js";
|
||||||
|
import { issueUserToken } from "./ctx-users.js";
|
||||||
import {
|
import {
|
||||||
getSshAuthProvider,
|
getSshAuthProvider,
|
||||||
listSshAuthProviders,
|
listSshAuthProviders,
|
||||||
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
|||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
||||||
},
|
},
|
||||||
|
issueUserToken: (userId, options) =>
|
||||||
|
issueUserToken(manifest, userId, options),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
|
||||||
|
* user, and a short-lived session for calling core's own API as one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginUserToken,
|
||||||
|
PluginUserTokenOptions,
|
||||||
|
PluginUsers,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
import { assertCapability } from "./permissions.js";
|
||||||
|
import { getActor } from "./actor.js";
|
||||||
|
|
||||||
|
const DEFAULT_TOKEN_TTL_SECONDS = 300;
|
||||||
|
const MAX_TOKEN_TTL_SECONDS = 900;
|
||||||
|
const MAX_PURPOSE_LENGTH = 200;
|
||||||
|
|
||||||
|
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
|
||||||
|
return {
|
||||||
|
getLocale: async (userId) => {
|
||||||
|
await assertCapability(
|
||||||
|
manifest.id,
|
||||||
|
"users:read",
|
||||||
|
manifest.capabilities,
|
||||||
|
"users_get_locale",
|
||||||
|
);
|
||||||
|
const { createCurrentUserPreferenceRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const preferences =
|
||||||
|
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||||
|
return preferences?.language ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueUserToken(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
userId: string,
|
||||||
|
options: PluginUserTokenOptions,
|
||||||
|
): Promise<PluginUserToken> {
|
||||||
|
await assertCapability(
|
||||||
|
manifest.id,
|
||||||
|
"users:impersonate",
|
||||||
|
manifest.capabilities,
|
||||||
|
"user_token_issued",
|
||||||
|
);
|
||||||
|
const { logAudit } = await import("../utils/audit-logger.js");
|
||||||
|
const purpose = String(options?.purpose ?? "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, MAX_PURPOSE_LENGTH);
|
||||||
|
const ttlSeconds = Math.min(
|
||||||
|
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
|
||||||
|
MAX_TOKEN_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
const audit = (success: boolean, errorMessage?: string) =>
|
||||||
|
logAudit({
|
||||||
|
userId: getActor() ?? null,
|
||||||
|
username: `plugin:${manifest.id}`,
|
||||||
|
action: "plugin_user_token_issued",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: userId,
|
||||||
|
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
|
||||||
|
success,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!purpose) {
|
||||||
|
throw new Error("ctx.auth.issueUserToken needs a purpose");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||||
|
throw new Error("ttlSeconds must be a positive whole number");
|
||||||
|
}
|
||||||
|
const { createCurrentUserRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const user = userId
|
||||||
|
? await createCurrentUserRepository().findById(userId)
|
||||||
|
: null;
|
||||||
|
if (!user) throw new Error(`User "${userId}" does not exist`);
|
||||||
|
|
||||||
|
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||||
|
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
|
||||||
|
pluginId: manifest.id,
|
||||||
|
purpose,
|
||||||
|
ttlSeconds,
|
||||||
|
});
|
||||||
|
await audit(true);
|
||||||
|
return issued;
|
||||||
|
} catch (error) {
|
||||||
|
await audit(false, error instanceof Error ? error.message : String(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
|||||||
import { createPluginProcess } from "./ctx-process.js";
|
import { createPluginProcess } from "./ctx-process.js";
|
||||||
import { createPluginSystem } from "./ctx-system.js";
|
import { createPluginSystem } from "./ctx-system.js";
|
||||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||||
|
import { createPluginUsers } from "./ctx-users.js";
|
||||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||||
|
|
||||||
@@ -957,6 +958,7 @@ export function createPluginContext(
|
|||||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||||
plugins: createPluginPlugins(manifest),
|
plugins: createPluginPlugins(manifest),
|
||||||
|
users: createPluginUsers(manifest),
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const TOPICS = {
|
|||||||
hostStatus: "host.status",
|
hostStatus: "host.status",
|
||||||
/** A host's key was accepted again after it changed. */
|
/** A host's key was accepted again after it changed. */
|
||||||
hostKeyUpdated: "host.key.updated",
|
hostKeyUpdated: "host.key.updated",
|
||||||
|
/** A host was added and anything inventorying hosts should pick it up. */
|
||||||
|
hostCreated: "host.created",
|
||||||
/** A host's connection details changed and pollers should re-read them. */
|
/** A host's connection details changed and pollers should re-read them. */
|
||||||
hostUpdated: "host.updated",
|
hostUpdated: "host.updated",
|
||||||
/** A host was deleted and any poller holding it should drop it. */
|
/** A host was deleted and any poller holding it should drop it. */
|
||||||
|
|||||||
@@ -499,17 +499,21 @@ export class SyncEngine {
|
|||||||
);
|
);
|
||||||
all.push(...result.changes);
|
all.push(...result.changes);
|
||||||
next = result.nextCursor;
|
next = result.nextCursor;
|
||||||
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
|
this.learnTypes(result);
|
||||||
this.state.serverTypes = result.entityTypes.map((entry) => ({
|
|
||||||
type: entry.type,
|
|
||||||
owner: entry.owner,
|
|
||||||
readOnly: entry.readOnly,
|
|
||||||
}));
|
|
||||||
if (!result.hasMore) break;
|
if (!result.hasMore) break;
|
||||||
}
|
}
|
||||||
return { changes: all, cursor: next };
|
return { changes: all, cursor: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private learnTypes(page: ChangesPage): void {
|
||||||
|
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
|
||||||
|
this.state.serverTypes = page.entityTypes.map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
|
owner: entry.owner,
|
||||||
|
readOnly: entry.readOnly,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async pull(link: SyncLink): Promise<void> {
|
private async pull(link: SyncLink): Promise<void> {
|
||||||
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
||||||
const known = new Set(link.knownTypes);
|
const known = new Set(link.knownTypes);
|
||||||
@@ -528,7 +532,14 @@ export class SyncEngine {
|
|||||||
/** Pulls every row of types this device has not pulled before. */
|
/** Pulls every row of types this device has not pulled before. */
|
||||||
private async backfill(link: SyncLink): Promise<void> {
|
private async backfill(link: SyncLink): Promise<void> {
|
||||||
if (!this.serverTypes) {
|
if (!this.serverTypes) {
|
||||||
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
|
// Only the entity types are wanted, so a one-row page is enough.
|
||||||
|
this.learnTypes(
|
||||||
|
await remoteJson<ChangesPage>(
|
||||||
|
link,
|
||||||
|
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
|
||||||
|
{ timeoutMs: 120_000 },
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const fresh = listEntities()
|
const fresh = listEntities()
|
||||||
.map((entity) => entity.type)
|
.map((entity) => entity.type)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||||
import { syncLogger } from "../utils/logger.js";
|
import { syncLogger } from "../utils/logger.js";
|
||||||
import { getPluginRuntime } from "../plugins/index.js";
|
import { getPluginRuntime } from "../plugins/index.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||||
import { singletonSyncId } from "./wire.js";
|
import { singletonSyncId } from "./wire.js";
|
||||||
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||||
import {
|
import {
|
||||||
@@ -174,7 +175,7 @@ export function registerCoreSyncEntities(): void {
|
|||||||
)
|
)
|
||||||
: {},
|
: {},
|
||||||
}),
|
}),
|
||||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
// The server owns host defaults: it keeps what the host set itself
|
// The server owns host defaults: it keeps what the host set itself
|
||||||
// and resolves the rest. A desktop takes the server's values as sent.
|
// and resolves the rest. A desktop takes the server's values as sent.
|
||||||
@@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void {
|
|||||||
const { SharedHostSecretsManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../utils/shared-host-secrets-manager.js");
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
||||||
|
if (created) {
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
remove: async (row, userId) => {
|
remove: async (row, userId) => {
|
||||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
|
|||||||
return rows.filter((row) => !!row.error);
|
return rows.filter((row) => !!row.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
|
||||||
|
const MAX_SEQ = 2_147_483_647;
|
||||||
|
|
||||||
export async function listChanges(
|
export async function listChanges(
|
||||||
userId: string,
|
userId: string,
|
||||||
cursor: number,
|
cursor: number,
|
||||||
@@ -148,7 +151,8 @@ export async function listChanges(
|
|||||||
): Promise<SyncRecord[]> {
|
): Promise<SyncRecord[]> {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(syncRecords.userId, userId),
|
eq(syncRecords.userId, userId),
|
||||||
gt(syncRecords.seq, cursor),
|
// Postgres rejects a bound value the column cannot hold.
|
||||||
|
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
|
||||||
];
|
];
|
||||||
if (types && types.length > 0) {
|
if (types && types.length > 0) {
|
||||||
conditions.push(inArray(syncRecords.entityType, types));
|
conditions.push(inArray(syncRecords.entityType, types));
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ function userOf(req: Request): string {
|
|||||||
return (req as AuthenticatedRequest).userId;
|
return (req as AuthenticatedRequest).userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A non-negative integer query value, the fallback when absent, null when malformed. */
|
||||||
|
function queryInt(value: unknown, fallback: number): number | null {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return typeof value === "string" &&
|
||||||
|
/^\d+$/.test(value) &&
|
||||||
|
Number.isSafeInteger(parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /sync/v2/info:
|
* /sync/v2/info:
|
||||||
@@ -224,6 +235,8 @@ router.post(
|
|||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: A page of changes, the next cursor and whether more remain.
|
* description: A page of changes, the next cursor and whether more remain.
|
||||||
|
* 400:
|
||||||
|
* description: The cursor or limit is not a non-negative integer.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Failed to read changes.
|
* description: Failed to read changes.
|
||||||
*/
|
*/
|
||||||
@@ -232,11 +245,12 @@ router.get(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = userOf(req);
|
const userId = userOf(req);
|
||||||
const cursor = Math.max(0, Number(req.query.cursor) || 0);
|
const cursor = queryInt(req.query.cursor, 0);
|
||||||
const limit = Math.min(
|
const pageSize = queryInt(req.query.limit, MAX_PAGE);
|
||||||
MAX_PAGE,
|
if (cursor === null || pageSize === null) {
|
||||||
Math.max(1, Number(req.query.limit) || MAX_PAGE),
|
return res.status(400).json({ error: "Invalid cursor or limit" });
|
||||||
);
|
}
|
||||||
|
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
|
||||||
const types =
|
const types =
|
||||||
typeof req.query.types === "string" && req.query.types
|
typeof req.query.types === "string" && req.query.types
|
||||||
? req.query.types.split(",").filter(Boolean)
|
? req.query.types.split(",").filter(Boolean)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function exportFile(): string {
|
|||||||
|
|
||||||
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
|
const events: Array<[string, unknown]> = [];
|
||||||
const hosts: Array<Record<string, unknown>> = [];
|
const hosts: Array<Record<string, unknown>> = [];
|
||||||
const importHostProtocolLogins = vi.fn();
|
const importHostProtocolLogins = vi.fn();
|
||||||
const handler = runInNewContext(
|
const handler = runInNewContext(
|
||||||
@@ -98,6 +99,11 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
|
|||||||
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||||
DatabaseSaveTrigger: { forceSave: async () => {} },
|
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||||
getErrorMessage: (error: Error) => error.message,
|
getErrorMessage: (error: Error) => error.message,
|
||||||
|
pluginEvents: {
|
||||||
|
emit: (topic: string, payload: unknown) =>
|
||||||
|
events.push([topic, payload]),
|
||||||
|
},
|
||||||
|
TOPICS: { hostCreated: "host.created" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const res = { json: vi.fn(), status: vi.fn() };
|
const res = { json: vi.fn(), status: vi.fn() };
|
||||||
@@ -106,7 +112,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
|
|||||||
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||||
res,
|
res,
|
||||||
);
|
);
|
||||||
return { res, writes, hosts, importHostProtocolLogins };
|
return { res, writes, hosts, events, importHostProtocolLogins };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("points imported hosts at the credentials imported with them", async () => {
|
it("points imported hosts at the credentials imported with them", async () => {
|
||||||
@@ -122,6 +128,17 @@ it("points imported hosts at the credentials imported with them", async () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publishes host.created once for each imported host", async () => {
|
||||||
|
const { events } = await invoke(exportFile());
|
||||||
|
|
||||||
|
expect(events).toEqual(
|
||||||
|
[501, 502, 503, 504].map((hostId) => [
|
||||||
|
"host.created",
|
||||||
|
{ hostId, userId: "user-1" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes the uploaded file when the user is gone", async () => {
|
it("removes the uploaded file when the user is gone", async () => {
|
||||||
const path = exportFile();
|
const path = exportFile();
|
||||||
const { res } = await invoke(path, null);
|
const { res } = await invoke(path, null);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
context: null as DatabaseContext | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentRepositoryContext: () => state.context,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: { triggerSave: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { listChanges } = await import("../../../sync/records.js");
|
||||||
|
|
||||||
|
// Runs against Postgres and MySQL too in the database-dialects CI job, where
|
||||||
|
// sync_records.seq is a 32-bit integer.
|
||||||
|
describe("listChanges", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
state.context = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
state.context = await adapter.connect();
|
||||||
|
await adapter.run(
|
||||||
|
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
|
||||||
|
);
|
||||||
|
await adapter.run(
|
||||||
|
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists records after the cursor in seq order", async () => {
|
||||||
|
await seed();
|
||||||
|
const records = await listChanges("user-1", 1, 10);
|
||||||
|
expect(records.map((record) => record.syncId)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers no records for a cursor past what seq can hold", async () => {
|
||||||
|
await seed();
|
||||||
|
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
|
|||||||
seen.push(payload);
|
seen.push(payload);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
notifyImportedHosts("user-1", [4, 7]);
|
notifyImportedHosts("user-1", [4, 7], []);
|
||||||
} finally {
|
} finally {
|
||||||
off();
|
off();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* The create and update host routes against a real SQLite database. A body
|
||||||
|
* that leaves out authType must never reach the NOT NULL auth_type column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, {
|
||||||
|
type NextFunction,
|
||||||
|
type Request,
|
||||||
|
type Response,
|
||||||
|
} from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
ctx: null as null | { drizzle: unknown },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const USER = "user-1";
|
||||||
|
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
|
||||||
|
|
||||||
|
vi.mock("../../../database/db/index.js", () => ({
|
||||||
|
getDb: () => state.ctx!.drizzle,
|
||||||
|
getSqlite: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||||
|
DatabaseSaveTrigger: {
|
||||||
|
triggerSave: vi.fn(),
|
||||||
|
forceSave: vi.fn(async () => {}),
|
||||||
|
batched: <T>(work: T) => work,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/system-crypto.js", () => ({
|
||||||
|
SystemCrypto: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getEncryptionKey: async () => Buffer.alloc(32, 7),
|
||||||
|
getJWTSecret: async () => "secret",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||||
|
DataCrypto: {
|
||||||
|
getUserDataKey: () => Buffer.alloc(32, 3),
|
||||||
|
validateUserAccess: () => Buffer.alloc(32, 3),
|
||||||
|
encryptRecord: (_table: string, record: unknown) => record,
|
||||||
|
decryptRecord: (_table: string, record: unknown) => record,
|
||||||
|
decryptRecords: (_table: string, records: unknown) => records,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() => (req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
(req as Request & { userId: string }).userId = USER;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
createDataAccessMiddleware: () => pass,
|
||||||
|
createAdminMiddleware: () => pass,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
requirePermission: () => pass,
|
||||||
|
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
|
||||||
|
hasPermission: async () => true,
|
||||||
|
isAdmin: async () => true,
|
||||||
|
getUserPermissions: async () => [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||||
|
logAudit: async () => {},
|
||||||
|
getAuditUsername: async () => USER,
|
||||||
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
|
||||||
|
const { hosts, sshCredentials } =
|
||||||
|
await import("../../../database/db/schema.js");
|
||||||
|
const { saveLevel } = await import("../../../hosts/defaults/service.js");
|
||||||
|
const { default: hostRouter } =
|
||||||
|
await import("../../../database/routes/host.js");
|
||||||
|
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/host", hostRouter);
|
||||||
|
|
||||||
|
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||||
|
let ctx: DatabaseContext;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = new TestSqliteDatabase("sqlite");
|
||||||
|
ctx = await db.connect();
|
||||||
|
state.ctx = ctx;
|
||||||
|
await ctx.drizzle.run(
|
||||||
|
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addCredential(): Promise<number> {
|
||||||
|
const [row] = await ctx.drizzle
|
||||||
|
.insert(sshCredentials)
|
||||||
|
.values({
|
||||||
|
userId: USER,
|
||||||
|
name: "ops",
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "secret",
|
||||||
|
})
|
||||||
|
.returning({ id: sshCredentials.id });
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storedAuthType(id: number) {
|
||||||
|
const [row] = await ctx.drizzle
|
||||||
|
.select({ authType: hosts.authType })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.id, id));
|
||||||
|
return row?.authType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = { ip: "10.0.0.5", port: 22, username: "root" };
|
||||||
|
|
||||||
|
describe("host create without authType", () => {
|
||||||
|
it("infers credential auth from a credentialId", async () => {
|
||||||
|
const credentialId = await addCredential();
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({ ...base, credentialId });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers password auth from a password", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({ ...base, password: "pw" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await storedAuthType(res.body.id)).toBe("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a body that says nothing about authentication", async () => {
|
||||||
|
const res = await request(app).post("/host/db/host").send(base);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/authType is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the auth type from the user's host defaults", async () => {
|
||||||
|
const credentialId = await addCredential();
|
||||||
|
await saveLevel(
|
||||||
|
{ level: "user", userId: USER },
|
||||||
|
{
|
||||||
|
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
|
||||||
|
unset: new Set(),
|
||||||
|
errors: {},
|
||||||
|
},
|
||||||
|
USER,
|
||||||
|
);
|
||||||
|
const res = await request(app).post("/host/db/host").send(base);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host update authType", () => {
|
||||||
|
it("keeps the stored auth type when the update leaves it out", async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({ ...base, authType: "password", password: "pw" });
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/host/db/host/${created.body.id}`)
|
||||||
|
.send({ ...base, name: "renamed" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await storedAuthType(created.body.id)).toBe("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an authType that is not a string", async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({ ...base, authType: "password", password: "pw" });
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/host/db/host/${created.body.id}`)
|
||||||
|
.send({ ...base, authType: { bad: true } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host.created", () => {
|
||||||
|
it("is published once when a host is created, and not on update", async () => {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||||
|
seen.push(payload);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const created = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({ ...base, authType: "password", password: "pw" });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
await request(app)
|
||||||
|
.put(`/host/db/host/${created.body.id}`)
|
||||||
|
.send({ ...base, name: "renamed" });
|
||||||
|
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,11 +37,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}));
|
}));
|
||||||
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
|
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
|
||||||
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
|
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
|
||||||
|
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
|
||||||
|
|
||||||
let handler: RequestHandler;
|
let handler: RequestHandler;
|
||||||
|
let sshConfigHandler: RequestHandler;
|
||||||
const router = {
|
const router = {
|
||||||
post: (path: string, ...handlers: RequestHandler[]) => {
|
post: (path: string, ...handlers: RequestHandler[]) => {
|
||||||
if (path === "/bulk-import") handler = handlers.at(-1)!;
|
if (path === "/bulk-import") handler = handlers.at(-1)!;
|
||||||
|
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
|
||||||
},
|
},
|
||||||
put: () => {},
|
put: () => {},
|
||||||
patch: () => {},
|
patch: () => {},
|
||||||
@@ -66,12 +69,13 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.create.mockResolvedValue({ id: 71 });
|
mocks.create.mockResolvedValue({ id: 71 });
|
||||||
mocks.update.mockResolvedValue({ id: 19 });
|
mocks.update.mockResolvedValue({ id: 19 });
|
||||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
mocks.list.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
"carries plugin host settings through export selection and import (overwrite=%s)",
|
"carries plugin host settings through export selection and import (overwrite=%s)",
|
||||||
async (overwrite) => {
|
async (overwrite) => {
|
||||||
|
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||||
const payload = buildExportPayload(
|
const payload = buildExportPayload(
|
||||||
{ hosts: [host] },
|
{ hosts: [host] },
|
||||||
null,
|
null,
|
||||||
@@ -168,6 +172,60 @@ it.each([20, "20"])(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("skips hosts that already exist when not overwriting", async () => {
|
||||||
|
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||||
|
const res = await importHosts([host]);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ success: 0, updated: 0, skipped: 1, failed: 0 }),
|
||||||
|
);
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.applyPluginSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a skipped jump host reference to its existing destination ID", async () => {
|
||||||
|
mocks.list.mockResolvedValue([{ ...host, ip: "192.0.2.2", id: 801 }]);
|
||||||
|
const res = await importHosts([
|
||||||
|
{ ...host, exportId: 10, jumpHosts: [{ hostId: 20 }] },
|
||||||
|
{ ...host, ip: "192.0.2.2", exportId: 20 },
|
||||||
|
]);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ success: 1, skipped: 1, failed: 0 }),
|
||||||
|
);
|
||||||
|
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.update).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.parse(mocks.create.mock.calls[0][1].jumpHosts)).toEqual([
|
||||||
|
{ hostId: 801 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[false, { success: 0, updated: 0, skipped: 1 }],
|
||||||
|
[true, { success: 0, updated: 1, skipped: 0 }],
|
||||||
|
])(
|
||||||
|
"SSH config import with an existing host (overwrite=%s)",
|
||||||
|
async (overwrite, counts) => {
|
||||||
|
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||||
|
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
|
||||||
|
await sshConfigHandler(
|
||||||
|
{
|
||||||
|
userId: "user-1",
|
||||||
|
body: {
|
||||||
|
content: "Host web\n HostName 192.0.2.1\n User alice\n Port 22\n",
|
||||||
|
overwrite,
|
||||||
|
},
|
||||||
|
} as unknown as Request,
|
||||||
|
res as unknown as Response,
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ ...counts, failed: 0 }),
|
||||||
|
);
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.update).toHaveBeenCalledTimes(overwrite ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{ ...host, jumpHosts: [{ hostId: 20 }] }],
|
[{ ...host, jumpHosts: [{ hostId: 20 }] }],
|
||||||
[{ ...host, jumpHosts: [{ hostId: "20" }] }],
|
[{ ...host, jumpHosts: [{ hostId: "20" }] }],
|
||||||
@@ -202,3 +260,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
|
|||||||
);
|
);
|
||||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function collectCreated() {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||||
|
seen.push(payload);
|
||||||
|
});
|
||||||
|
return { seen, off };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("publishes host.created once for each host a JSON import creates", async () => {
|
||||||
|
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||||
|
mocks.create.mockResolvedValueOnce({ id: 501 });
|
||||||
|
const { seen, off } = collectCreated();
|
||||||
|
try {
|
||||||
|
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
// The overwritten host already existed, so only the new one is announced.
|
||||||
|
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes host.created once for each host an SSH config import creates", async () => {
|
||||||
|
mocks.create
|
||||||
|
.mockResolvedValueOnce({ id: 601 })
|
||||||
|
.mockResolvedValueOnce({ id: 602 });
|
||||||
|
const { seen, off } = collectCreated();
|
||||||
|
try {
|
||||||
|
await sshConfigHandler(
|
||||||
|
{
|
||||||
|
userId: "user-1",
|
||||||
|
body: {
|
||||||
|
content:
|
||||||
|
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
|
||||||
|
},
|
||||||
|
} as unknown as Request,
|
||||||
|
{
|
||||||
|
json: vi.fn(),
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response,
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([
|
||||||
|
{ hostId: 601, userId: "user-1" },
|
||||||
|
{ hostId: 602, userId: "user-1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
|
|||||||
import {
|
import {
|
||||||
applyHostKeyTypeUpdate,
|
applyHostKeyTypeUpdate,
|
||||||
containsOwnerPrivateAuthUpdate,
|
containsOwnerPrivateAuthUpdate,
|
||||||
|
impliedSshAuthType,
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
keepsStoredSshAuth,
|
keepsStoredSshAuth,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
@@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => {
|
|||||||
expect(keepsStoredSshAuth("none")).toBe(false);
|
expect(keepsStoredSshAuth("none")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("impliedSshAuthType", () => {
|
||||||
|
it("reads the auth type from the secret a create body carries", () => {
|
||||||
|
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
|
||||||
|
"credential",
|
||||||
|
);
|
||||||
|
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
|
||||||
|
"key",
|
||||||
|
);
|
||||||
|
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
|
||||||
|
expect(impliedSshAuthType({})).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||||
|
setLinkedServer: (config: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL("../../../../electron/main.cjs", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const tls = source.slice(
|
||||||
|
source.indexOf("const INSECURE_MODE_VALUES"),
|
||||||
|
source.indexOf("function httpFetch("),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ORIGIN = "https://termix.example";
|
||||||
|
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
|
||||||
|
|
||||||
|
function load(link: Record<string, unknown> | null) {
|
||||||
|
linkedServer.setLinkedServer(link);
|
||||||
|
const context = vm.createContext({
|
||||||
|
URL,
|
||||||
|
process: { env: {} },
|
||||||
|
linkedServer,
|
||||||
|
});
|
||||||
|
vm.runInContext(tls, context);
|
||||||
|
return context as unknown as {
|
||||||
|
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
|
||||||
|
getWebSocketOptions: (
|
||||||
|
url: string,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
) => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("linked server certificate exception", () => {
|
||||||
|
it("covers WebSockets to the linked server", () => {
|
||||||
|
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||||
|
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
|
||||||
|
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
|
||||||
|
headers: {},
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"ws://termix.example/relay",
|
||||||
|
"wss://termix.example:8443/relay",
|
||||||
|
"wss://elsewhere.example/relay",
|
||||||
|
])("keeps verifying %s", (url) => {
|
||||||
|
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||||
|
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps verifying when the link does not allow it", () => {
|
||||||
|
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
|
||||||
|
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
|
||||||
|
rejectUnauthorized: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,48 @@ describe("linked server in Electron main", () => {
|
|||||||
).toEqual({ Accept: "x" });
|
).toEqual({ Accept: "x" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
|
||||||
|
linked.setLinkedServer({ origin: ORIGIN });
|
||||||
|
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
|
||||||
|
expect(
|
||||||
|
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
|
||||||
|
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
|
||||||
|
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
|
||||||
|
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
|
||||||
|
expect(linked.isLinkedOrigin("not a url")).toBe(false);
|
||||||
|
|
||||||
|
linked.setLinkedServer({ origin: "http://termix.example:8080" });
|
||||||
|
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
|
||||||
|
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
|
||||||
|
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
|
||||||
|
linked.setLinkedServer({
|
||||||
|
origin: ORIGIN,
|
||||||
|
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||||
|
basicAuth: { username: "gate", password: "pw" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
linked.applyLinkHeaders(
|
||||||
|
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
|
||||||
|
{ Upgrade: "websocket" },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
Upgrade: "websocket",
|
||||||
|
"CF-Access-Client-Id": "id",
|
||||||
|
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
|
||||||
|
).toEqual({ username: "gate", password: "pw" });
|
||||||
|
expect(
|
||||||
|
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
|
||||||
|
).toEqual({ Upgrade: "websocket" });
|
||||||
|
});
|
||||||
|
|
||||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||||
linked.setLinkedServer({
|
linked.setLinkedServer({
|
||||||
origin: ORIGIN,
|
origin: ORIGIN,
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
|
|||||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||||
const { PluginCapabilityError } =
|
const { PluginCapabilityError } =
|
||||||
await import("@termix-ssh/plugin-sdk/backend");
|
await import("@termix-ssh/plugin-sdk/backend");
|
||||||
|
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||||
|
|
||||||
function manifest(capabilities: string[]) {
|
function manifest(capabilities: string[]) {
|
||||||
return {
|
return {
|
||||||
@@ -436,6 +437,29 @@ describe("ctx.hosts", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("publishes host.created for a host it creates", async () => {
|
||||||
|
h.granted = new Set(["hosts:write"]);
|
||||||
|
const hosts = createPluginHosts({
|
||||||
|
manifest: manifest(["hosts:write"]),
|
||||||
|
audit: vi.fn(async () => {}),
|
||||||
|
});
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||||
|
seen.push(payload);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await hosts.create({
|
||||||
|
ip: "10.0.0.5",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||||
h.granted = new Set(["hosts:write"]);
|
h.granted = new Set(["hosts:write"]);
|
||||||
const hosts = createPluginHosts({
|
const hosts = createPluginHosts({
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* ctx.auth.issueUserToken mints a short-lived session core's own auth
|
||||||
|
* middleware accepts as the named user, under users:impersonate, audited and
|
||||||
|
* kept off account security routes. ctx.users.getLocale reads the user's UI
|
||||||
|
* language under users:read.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "crypto";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
grants: [] as string[],
|
||||||
|
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
|
||||||
|
sessions: new Map<string, Record<string, unknown>>(),
|
||||||
|
languages: new Map<string, string | null>(),
|
||||||
|
audits: [] as Array<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/db/index.js", () => ({
|
||||||
|
db: {},
|
||||||
|
getDb: () => ({}),
|
||||||
|
saveMemoryDatabaseToFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentPluginPermissionGrantRepository: () => ({
|
||||||
|
listByPlugin: async (pluginId: string) =>
|
||||||
|
state.grants.map((capability) => ({ pluginId, capability })),
|
||||||
|
}),
|
||||||
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||||
|
createCurrentSessionRepository: () => ({
|
||||||
|
create: async (session: Record<string, unknown>) => {
|
||||||
|
state.sessions.set(session.id as string, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
findById: async (id: string) => state.sessions.get(id) ?? null,
|
||||||
|
touch: async () => {},
|
||||||
|
revoke: async (id: string) => state.sessions.delete(id),
|
||||||
|
}),
|
||||||
|
createCurrentUserRepository: () => ({
|
||||||
|
findById: async (id: string) => state.users.get(id) ?? null,
|
||||||
|
}),
|
||||||
|
createCurrentUserPreferenceRepository: () => ({
|
||||||
|
findByUserId: async (userId: string) =>
|
||||||
|
state.languages.has(userId)
|
||||||
|
? { userId, language: state.languages.get(userId) }
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
createCurrentApiKeyRepository: () => ({}),
|
||||||
|
createCurrentTrustedDeviceRepository: () => ({}),
|
||||||
|
getCurrentSettingValue: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/audit-logger.js", () => ({
|
||||||
|
logAudit: async (entry: Record<string, unknown>) => {
|
||||||
|
state.audits.push(entry);
|
||||||
|
},
|
||||||
|
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/system-crypto.js", () => ({
|
||||||
|
SystemCrypto: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getJWTSecret: async () => "a".repeat(64),
|
||||||
|
getEncryptionKey: async () => crypto.randomBytes(32),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/user-keys.js", () => ({
|
||||||
|
UserKeyManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
hasUserDEK: () => true,
|
||||||
|
tryGetUserDEK: () => null,
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
|
||||||
|
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||||
|
migratePasswordUserAtLogin: vi.fn(async () => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createPluginContext, createPluginHandle } =
|
||||||
|
await import("../../plugins/ctx.js");
|
||||||
|
const { invalidatePluginPermissionCache } =
|
||||||
|
await import("../../plugins/permissions.js");
|
||||||
|
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||||
|
const { registerSecondFactor, resetAuthRegistryForTests } =
|
||||||
|
await import("../../auth/registry.js");
|
||||||
|
const { PluginCapabilityError } =
|
||||||
|
await import("@termix-ssh/plugin-sdk/backend");
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticate = authManager.createAuthMiddleware();
|
||||||
|
const requireAdmin = authManager.createAdminMiddleware();
|
||||||
|
|
||||||
|
function contextFor(capabilities: string[], grants = capabilities) {
|
||||||
|
state.grants = grants;
|
||||||
|
const manifest = {
|
||||||
|
id: "ai",
|
||||||
|
name: "AI",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities,
|
||||||
|
} as never;
|
||||||
|
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Middleware = typeof authenticate;
|
||||||
|
|
||||||
|
async function call(middleware: Middleware, token: string, url: string) {
|
||||||
|
const req = {
|
||||||
|
cookies: {},
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
method: "POST",
|
||||||
|
originalUrl: url,
|
||||||
|
url,
|
||||||
|
} as unknown as Parameters<Middleware>[0] & { userId?: string };
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null as unknown,
|
||||||
|
status(code: number) {
|
||||||
|
res.statusCode = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json(body: unknown) {
|
||||||
|
res.body = body;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
clearCookie() {
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let passed = false;
|
||||||
|
await middleware(req, res as never, () => {
|
||||||
|
passed = true;
|
||||||
|
});
|
||||||
|
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.users = new Map([
|
||||||
|
["u1", { id: "u1", username: "alice", isAdmin: false }],
|
||||||
|
["admin", { id: "admin", username: "root", isAdmin: true }],
|
||||||
|
]);
|
||||||
|
state.sessions.clear();
|
||||||
|
state.languages.clear();
|
||||||
|
state.audits = [];
|
||||||
|
invalidatePluginPermissionCache();
|
||||||
|
resetAuthRegistryForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.auth.issueUserToken", () => {
|
||||||
|
it("mints a session core's auth middleware accepts as that user", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
|
||||||
|
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||||
|
purpose: "nightly report",
|
||||||
|
});
|
||||||
|
const result = await call(authenticate, token, "/host/db/host");
|
||||||
|
|
||||||
|
expect(result.passed).toBe(true);
|
||||||
|
expect(result.userId).toBe("u1");
|
||||||
|
const session = [...state.sessions.values()][0];
|
||||||
|
expect(session).toMatchObject({
|
||||||
|
userId: "u1",
|
||||||
|
deviceType: "plugin",
|
||||||
|
deviceInfo: "ai: nightly report",
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||||
|
expect(payload.exp - payload.iat).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the user's own permissions", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
|
||||||
|
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
|
||||||
|
|
||||||
|
const refused = await call(requireAdmin, user.token, "/users/list");
|
||||||
|
expect(refused.passed).toBe(false);
|
||||||
|
expect(refused.status).toBe(403);
|
||||||
|
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the lifetime at 900 seconds", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
|
||||||
|
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||||
|
purpose: "long task",
|
||||||
|
ttlSeconds: 86_400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||||
|
expect(payload.exp - payload.iat).toBe(900);
|
||||||
|
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
|
||||||
|
900_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops working when the session expires", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
const { token } = await ctx.auth.issueUserToken("u1", {
|
||||||
|
purpose: "short",
|
||||||
|
ttlSeconds: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers({ now: Date.now() + 61_000 });
|
||||||
|
const result = await call(authenticate, token, "/host/db/host");
|
||||||
|
|
||||||
|
expect(result.passed).toBe(false);
|
||||||
|
expect(result.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audits every issue with the plugin, the user and the purpose", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
|
||||||
|
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
|
||||||
|
|
||||||
|
const entry = state.audits.find(
|
||||||
|
(audit) => audit.action === "plugin_user_token_issued",
|
||||||
|
);
|
||||||
|
expect(entry).toMatchObject({
|
||||||
|
username: "plugin:ai",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: "u1",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(entry!.details as string)).toEqual({
|
||||||
|
pluginId: "ai",
|
||||||
|
purpose: "nightly report",
|
||||||
|
ttlSeconds: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused without a granted users:impersonate", async () => {
|
||||||
|
for (const ctx of [
|
||||||
|
contextFor([]),
|
||||||
|
contextFor(["users:impersonate"], []),
|
||||||
|
contextFor(["users:read"]),
|
||||||
|
]) {
|
||||||
|
await expect(
|
||||||
|
ctx.auth.issueUserToken("u1", { purpose: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
}
|
||||||
|
expect(state.sessions.size).toBe(0);
|
||||||
|
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused for a user that does not exist, or without a purpose", async () => {
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
|
||||||
|
).rejects.toThrow(/does not exist/);
|
||||||
|
await expect(
|
||||||
|
ctx.auth.issueUserToken("u1", { purpose: " " }),
|
||||||
|
).rejects.toThrow(/purpose/);
|
||||||
|
expect(state.sessions.size).toBe(0);
|
||||||
|
expect(
|
||||||
|
state.audits.filter(
|
||||||
|
(audit) =>
|
||||||
|
audit.action === "plugin_user_token_issued" && !audit.success,
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot change how the user signs in or mint lasting credentials", async () => {
|
||||||
|
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
|
||||||
|
const ctx = contextFor(["users:impersonate"]);
|
||||||
|
const { token } = await ctx.auth.issueUserToken("admin", {
|
||||||
|
purpose: "ops",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const url of [
|
||||||
|
"/users/change-password",
|
||||||
|
"/users/link-external-to-password",
|
||||||
|
"/users/admin/reset-password",
|
||||||
|
"/users/admin/u1/second-factors",
|
||||||
|
"/plugin-api/totp/setup",
|
||||||
|
]) {
|
||||||
|
const result = await call(authenticate, token, url);
|
||||||
|
expect(result.passed, url).toBe(false);
|
||||||
|
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||||
|
}
|
||||||
|
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
|
||||||
|
expect(apiKeys.passed).toBe(false);
|
||||||
|
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||||
|
expect(
|
||||||
|
(await call(authenticate, token, "/plugin-api/other/run")).passed,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a normal login session free to use those routes", async () => {
|
||||||
|
const token = await authManager.generateJWTToken("u1", {
|
||||||
|
deviceType: "web",
|
||||||
|
deviceInfo: "Chrome",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await call(authenticate, token, "/users/change-password")).passed,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ctx.users.getLocale", () => {
|
||||||
|
it("returns the stored language, or null", async () => {
|
||||||
|
state.languages.set("u1", "zh_CN");
|
||||||
|
state.languages.set("admin", null);
|
||||||
|
const ctx = contextFor(["users:read"]);
|
||||||
|
|
||||||
|
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
|
||||||
|
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
|
||||||
|
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refused without a granted users:read", async () => {
|
||||||
|
state.languages.set("u1", "zh_CN");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
contextFor(["users:read"], []).users.getLocale("u1"),
|
||||||
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
await expect(
|
||||||
|
contextFor(["users:impersonate"]).users.getLocale("u1"),
|
||||||
|
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,7 +75,8 @@ const UNGATED: Record<string, string> = {
|
|||||||
"schedule.every": "the plugin's own timers",
|
"schedule.every": "the plugin's own timers",
|
||||||
"schedule.after": "the plugin's own timers",
|
"schedule.after": "the plugin's own timers",
|
||||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||||
asUser: "always audited as plugin_as_user",
|
asUser:
|
||||||
|
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
||||||
currentActor: "reads the actor, cannot set it",
|
currentActor: "reads the actor, cannot set it",
|
||||||
"desktop.available": "a yes/no about the environment",
|
"desktop.available": "a yes/no about the environment",
|
||||||
"http.baseUrl": "reads the request's public origin",
|
"http.baseUrl": "reads the request's public origin",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
|
||||||
|
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
|
||||||
|
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||||
|
vi.mock("../../../plugins/sync-registry.js", () => ({
|
||||||
|
getEntity: vi.fn(),
|
||||||
|
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||||
|
vi.mock("../../../sync/entities.js", () => ({
|
||||||
|
registerCoreSyncEntities: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../sync/records.js", () => ({
|
||||||
|
listChanges,
|
||||||
|
recordKey: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../sync/server/feed.js", () => ({
|
||||||
|
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||||
|
reconcileUser: async () => ({ wire: new Map() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../sync/server/push.js", () => ({}));
|
||||||
|
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
|
||||||
|
|
||||||
|
import router from "../../../sync/server/routes.js";
|
||||||
|
|
||||||
|
const route = router.stack.find(
|
||||||
|
(layer) => layer.route?.path === "/v2/changes",
|
||||||
|
)!.route!;
|
||||||
|
const handler = route.stack[route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listChanges.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function changes(query: Record<string, string>) {
|
||||||
|
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||||
|
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("answers an empty page with the entity types past the newest change", async () => {
|
||||||
|
const cursor = String(Number.MAX_SAFE_INTEGER);
|
||||||
|
const res = await changes({ cursor, limit: "1000" });
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
changes: [],
|
||||||
|
nextCursor: Number.MAX_SAFE_INTEGER,
|
||||||
|
hasMore: false,
|
||||||
|
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a missing cursor and limit", async () => {
|
||||||
|
await changes({});
|
||||||
|
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ cursor: "abc" },
|
||||||
|
{ cursor: "-1" },
|
||||||
|
{ cursor: "1.5" },
|
||||||
|
{ cursor: "Infinity" },
|
||||||
|
{ limit: "1.5" },
|
||||||
|
{ limit: "-5" },
|
||||||
|
])("rejects a malformed query %o", async (query) => {
|
||||||
|
const res = await changes(query);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(listChanges).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
|
|||||||
server: null as null | { drizzle: unknown },
|
server: null as null | { drizzle: unknown },
|
||||||
allow: true,
|
allow: true,
|
||||||
sharedHostIds: new Set<number>(),
|
sharedHostIds: new Set<number>(),
|
||||||
|
paths: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SERVER_USER = "srv-user";
|
const SERVER_USER = "srv-user";
|
||||||
@@ -123,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
|||||||
path: string,
|
path: string,
|
||||||
init: { method?: string; body?: unknown } = {},
|
init: { method?: string; body?: unknown } = {},
|
||||||
) => {
|
) => {
|
||||||
|
state.paths.push(path);
|
||||||
const previous = state.current;
|
const previous = state.current;
|
||||||
state.current = state.server;
|
state.current = state.server;
|
||||||
try {
|
try {
|
||||||
@@ -161,6 +163,7 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
|||||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||||
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
||||||
const { syncLogger } = await import("../../utils/logger.js");
|
const { syncLogger } = await import("../../utils/logger.js");
|
||||||
|
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: "5mb" }));
|
app.use(express.json({ limit: "5mb" }));
|
||||||
@@ -249,6 +252,7 @@ beforeEach(async () => {
|
|||||||
resetSeqAllocator();
|
resetSeqAllocator();
|
||||||
state.allow = true;
|
state.allow = true;
|
||||||
state.sharedHostIds.clear();
|
state.sharedHostIds.clear();
|
||||||
|
state.paths = [];
|
||||||
server = await side(SERVER_USER);
|
server = await side(SERVER_USER);
|
||||||
state.server = server.ctx;
|
state.server = server.ctx;
|
||||||
desktops = [await side("desk-a"), await side("desk-b")];
|
desktops = [await side("desk-a"), await side("desk-b")];
|
||||||
@@ -317,6 +321,60 @@ describe("linking a desktop", () => {
|
|||||||
expect(host.ip).toBe("new");
|
expect(host.ip).toBe("new");
|
||||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never asks for a cursor past what the server's seq column holds", async () => {
|
||||||
|
const [desk] = desktops;
|
||||||
|
await on(server, () => addHost(server, { name: "srv" }));
|
||||||
|
|
||||||
|
const engine = await linkDesktop(desk);
|
||||||
|
await pass(desk, engine);
|
||||||
|
|
||||||
|
const cursors = state.paths
|
||||||
|
.filter((path) => path.startsWith("/sync/v2/changes?"))
|
||||||
|
.map((path) =>
|
||||||
|
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
|
||||||
|
);
|
||||||
|
expect(cursors.length).toBeGreaterThan(0);
|
||||||
|
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a synced host", () => {
|
||||||
|
it("publishes host.created when it is written for the first time only", async () => {
|
||||||
|
const [desk] = desktops;
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||||
|
seen.push(payload);
|
||||||
|
});
|
||||||
|
const wire = {
|
||||||
|
syncId: "host-new",
|
||||||
|
name: "new",
|
||||||
|
ip: "10.0.0.2",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await on(desk, async () => {
|
||||||
|
const resolvers = createResolvers(desk.userId);
|
||||||
|
const first = await writeWireRow(
|
||||||
|
getEntity("hosts")!,
|
||||||
|
desk.userId,
|
||||||
|
wire,
|
||||||
|
resolvers,
|
||||||
|
);
|
||||||
|
await writeWireRow(
|
||||||
|
getEntity("hosts")!,
|
||||||
|
desk.userId,
|
||||||
|
{ ...wire, name: "renamed" },
|
||||||
|
resolvers,
|
||||||
|
);
|
||||||
|
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
off();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("after linking", () => {
|
describe("after linking", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
const createMock = vi.fn().mockResolvedValue(undefined);
|
const createMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -60,6 +60,101 @@ describe("logAudit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("logAudit plugin routine coalescing", () => {
|
||||||
|
const WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
let run = 0;
|
||||||
|
let details = "";
|
||||||
|
|
||||||
|
const routine = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
logAudit({
|
||||||
|
userId: null,
|
||||||
|
username: "plugin:automations",
|
||||||
|
action: "plugin_as_user",
|
||||||
|
resourceType: "plugin",
|
||||||
|
resourceId: "automations",
|
||||||
|
details,
|
||||||
|
success: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
createMock.mockResolvedValue(undefined);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
// The coalescing memory is module state: a fresh key per test keeps them
|
||||||
|
// independent.
|
||||||
|
details = `automations ran background work as user-${++run}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
|
||||||
|
await routine();
|
||||||
|
vi.advanceTimersByTime(WINDOW_MS - 1);
|
||||||
|
await routine();
|
||||||
|
await routine();
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes one row for identical successful plugin service calls in the window", async () => {
|
||||||
|
const service = {
|
||||||
|
action: "plugin_service_host_metrics_open",
|
||||||
|
resourceId: "host-metrics",
|
||||||
|
};
|
||||||
|
await routine(service);
|
||||||
|
await routine(service);
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes again once the window has passed", async () => {
|
||||||
|
await routine();
|
||||||
|
vi.advanceTimersByTime(WINDOW_MS);
|
||||||
|
await routine();
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always writes failures", async () => {
|
||||||
|
await routine();
|
||||||
|
await routine({ success: false, errorMessage: "boom" });
|
||||||
|
await routine({ success: false, errorMessage: "boom" });
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never coalesces non-plugin or non-routine actions", async () => {
|
||||||
|
const login = {
|
||||||
|
userId: "user-1",
|
||||||
|
username: "alice",
|
||||||
|
action: "login",
|
||||||
|
resourceType: "user",
|
||||||
|
};
|
||||||
|
await routine(login);
|
||||||
|
await routine(login);
|
||||||
|
const secretRead = { action: "plugin_secret_shared_read" };
|
||||||
|
await routine(secretRead);
|
||||||
|
await routine(secretRead);
|
||||||
|
const notPlugin = { username: "alice" };
|
||||||
|
await routine(notPlugin);
|
||||||
|
await routine(notPlugin);
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not coalesce entries with different details or resourceId", async () => {
|
||||||
|
await routine();
|
||||||
|
await routine({ details: `${details} (other)` });
|
||||||
|
await routine({ resourceId: "other-plugin" });
|
||||||
|
await routine({ userId: "user-2" });
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getRequestMeta", () => {
|
describe("getRequestMeta", () => {
|
||||||
it("uses the proxy-validated Express IP", () => {
|
it("uses the proxy-validated Express IP", () => {
|
||||||
const req = {
|
const req = {
|
||||||
|
|||||||
@@ -35,7 +35,49 @@ export interface AuditLogParams {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
|
||||||
|
// service) repeat identically every few seconds; written each time they push
|
||||||
|
// real security entries out of the capped log. Only successful plugin_as_user
|
||||||
|
// and plugin_service_* lines are coalesced; failures and everything else are
|
||||||
|
// always written.
|
||||||
|
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
|
||||||
|
const ROUTINE_COALESCE_MAX_KEYS = 5000;
|
||||||
|
const routineLastWritten = new Map<string, number>();
|
||||||
|
|
||||||
|
function isCoalescedRoutine(params: AuditLogParams): boolean {
|
||||||
|
if (
|
||||||
|
!params.success ||
|
||||||
|
!params.username.startsWith("plugin:") ||
|
||||||
|
!(
|
||||||
|
params.action === "plugin_as_user" ||
|
||||||
|
params.action.startsWith("plugin_service_")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const key = JSON.stringify([
|
||||||
|
params.userId,
|
||||||
|
params.username,
|
||||||
|
params.action,
|
||||||
|
params.resourceType,
|
||||||
|
params.resourceId,
|
||||||
|
params.details,
|
||||||
|
]);
|
||||||
|
const now = Date.now();
|
||||||
|
const last = routineLastWritten.get(key);
|
||||||
|
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
|
||||||
|
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
|
||||||
|
for (const [k, at] of routineLastWritten) {
|
||||||
|
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routineLastWritten.set(key, now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||||
|
if (isCoalescedRoutine(params)) return;
|
||||||
|
|
||||||
// Local storage is the source of truth and runs first; forwarding is a copy
|
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||||
// and must never delay or fail the audited operation.
|
// and must never delay or fail the audited operation.
|
||||||
void forwardAuditEntry(params).catch(() => {});
|
void forwardAuditEntry(params).catch(() => {});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { inArray, isNotNull } from "drizzle-orm";
|
import { inArray, isNotNull } from "drizzle-orm";
|
||||||
import type { ExternalSessionRef } from "../auth/types.js";
|
import type { ExternalSessionRef } from "../auth/types.js";
|
||||||
|
import { listSecondFactors } from "../auth/registry.js";
|
||||||
import type { DeviceType } from "./user-agent-parser.js";
|
import type { DeviceType } from "./user-agent-parser.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sessions } from "../database/db/schema.js";
|
import { sessions } from "../database/db/schema.js";
|
||||||
@@ -44,6 +45,11 @@ interface JWTPayload {
|
|||||||
pendingTOTP?: boolean;
|
pendingTOTP?: boolean;
|
||||||
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
||||||
purpose?: "ws";
|
purpose?: "ws";
|
||||||
|
/**
|
||||||
|
* A plugin minted the session. Read from the session row on verify, never
|
||||||
|
* from the token, so a refreshed token stays marked.
|
||||||
|
*/
|
||||||
|
pluginSession?: boolean;
|
||||||
dataKeyWrap?: WrappedDataKey;
|
dataKeyWrap?: WrappedDataKey;
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
@@ -103,6 +109,26 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
|||||||
|
|
||||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||||
|
|
||||||
|
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
|
||||||
|
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
|
||||||
|
|
||||||
|
// Routes that change how a user signs in or mint a credential that outlives
|
||||||
|
// the session. A plugin-minted session is refused on them, and on every
|
||||||
|
// route of a plugin that provides a second factor.
|
||||||
|
const PLUGIN_SESSION_BLOCKLIST = [
|
||||||
|
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
|
||||||
|
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whether a session a plugin minted may call this path. */
|
||||||
|
export function allowsPluginSession(path: string): boolean {
|
||||||
|
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||||
|
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||||
|
}
|
||||||
|
|
||||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||||
|
|
||||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||||
@@ -407,6 +433,38 @@ class AuthManager {
|
|||||||
} as jwt.SignOptions);
|
} as jwt.SignOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session a plugin mints for a user, to call core's API as them. It is
|
||||||
|
* an ordinary session row, so it verifies, expires and shows up like a
|
||||||
|
* login, marked with the plugin and why.
|
||||||
|
*/
|
||||||
|
async issuePluginSession(
|
||||||
|
userId: string,
|
||||||
|
options: { pluginId: string; purpose: string; ttlSeconds: number },
|
||||||
|
): Promise<{ token: string; expiresAt: string }> {
|
||||||
|
const sessionId = nanoid();
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId, sessionId } satisfies JWTPayload,
|
||||||
|
await this.systemCrypto.getJWTSecret(),
|
||||||
|
{ expiresIn: options.ttlSeconds },
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(
|
||||||
|
now.getTime() + options.ttlSeconds * 1000,
|
||||||
|
).toISOString();
|
||||||
|
await createCurrentSessionRepository().create({
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
jwtToken: token,
|
||||||
|
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
|
||||||
|
deviceInfo: `${options.pluginId}: ${options.purpose}`,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
expiresAt,
|
||||||
|
lastActiveAt: now.toISOString(),
|
||||||
|
});
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
async verifyJWTToken(
|
async verifyJWTToken(
|
||||||
token: string,
|
token: string,
|
||||||
options: { allowSocketTicket?: boolean } = {},
|
options: { allowSocketTicket?: boolean } = {},
|
||||||
@@ -440,6 +498,8 @@ class AuthManager {
|
|||||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
payload.pluginSession =
|
||||||
|
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||||
|
|
||||||
await this.migrateDataKeyFromPayload(payload);
|
await this.migrateDataKeyFromPayload(payload);
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
@@ -787,6 +847,8 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.refusePluginSession(req, res, payload)) return;
|
||||||
|
|
||||||
if (payload.sessionId) {
|
if (payload.sessionId) {
|
||||||
try {
|
try {
|
||||||
const sessionRepository = createCurrentSessionRepository();
|
const sessionRepository = createCurrentSessionRepository();
|
||||||
@@ -953,6 +1015,21 @@ class AuthManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends the 403 and returns true when a plugin session may not use this route. */
|
||||||
|
private refusePluginSession(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
payload: JWTPayload,
|
||||||
|
): boolean {
|
||||||
|
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||||
|
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||||
|
res.status(403).json({
|
||||||
|
error: "A session a plugin issued cannot use this route",
|
||||||
|
code: "PLUGIN_SESSION_NOT_ALLOWED",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
createDataAccessMiddleware() {
|
createDataAccessMiddleware() {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const authReq = req as AuthenticatedRequest;
|
const authReq = req as AuthenticatedRequest;
|
||||||
@@ -1007,6 +1084,8 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.refusePluginSession(req, res, payload)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await createCurrentUserRepository().findById(
|
const user = await createCurrentUserRepository().findById(
|
||||||
payload.userId,
|
payload.userId,
|
||||||
|
|||||||
+27
-2
@@ -6,6 +6,8 @@ import "./ui/index.css";
|
|||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import "./ui/i18n/i18n";
|
import "./ui/i18n/i18n";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||||
|
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
|
||||||
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
||||||
import { Toaster } from "@/components/sonner";
|
import { Toaster } from "@/components/sonner";
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +86,15 @@ function FullscreenApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-out browser gets the normal login for a full-screen link and comes
|
||||||
|
* back to it afterwards. The desktop app and the embedded sign-in frames
|
||||||
|
* hand a login to their own shell, so they keep the bare view.
|
||||||
|
*/
|
||||||
|
function signsInForViewLink(): boolean {
|
||||||
|
return !isElectron() && !isEmbeddedAuthFrame();
|
||||||
|
}
|
||||||
|
|
||||||
function FullscreenAppGate() {
|
function FullscreenAppGate() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -109,7 +120,16 @@ function FullscreenAppGate() {
|
|||||||
if (!cancelled) setReady(true);
|
if (!cancelled) setReady(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setAuthFailed(true);
|
if (cancelled) return;
|
||||||
|
if (signsInForViewLink()) {
|
||||||
|
rememberViewLink();
|
||||||
|
window.history.replaceState(
|
||||||
|
{},
|
||||||
|
document.title,
|
||||||
|
window.location.pathname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setAuthFailed(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -118,7 +138,7 @@ function FullscreenAppGate() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (authFailed) {
|
if (authFailed) {
|
||||||
return <FullscreenApp />;
|
return signsInForViewLink() ? <App /> : <FullscreenApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -181,6 +201,7 @@ function App() {
|
|||||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (resumeViewLink()) return;
|
||||||
setPhase("loading-app");
|
setPhase("loading-app");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
startPluginRuntime()
|
startPluginRuntime()
|
||||||
@@ -262,6 +283,10 @@ function App() {
|
|||||||
function handleLogin(u: string) {
|
function handleLogin(u: string) {
|
||||||
loggingOutRef.current = false;
|
loggingOutRef.current = false;
|
||||||
clearDesktopManualLogout();
|
clearDesktopManualLogout();
|
||||||
|
if (resumeViewLink()) {
|
||||||
|
setPhase("loading-app");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAuthUsername(u);
|
setAuthUsername(u);
|
||||||
setPhase("loading-app");
|
setPhase("loading-app");
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
+9
-14
@@ -125,6 +125,7 @@ import type {
|
|||||||
} from "@/types/ui-types";
|
} from "@/types/ui-types";
|
||||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||||
|
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||||
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import {
|
import {
|
||||||
@@ -829,20 +830,11 @@ export function AppShell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+Arrow moves between panes of the active split
|
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
const paneDirection = getPaneNavDirection(e);
|
||||||
const direction = (
|
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||||
{
|
e.preventDefault();
|
||||||
ArrowLeft: "left",
|
return;
|
||||||
ArrowRight: "right",
|
|
||||||
ArrowUp: "up",
|
|
||||||
ArrowDown: "down",
|
|
||||||
} as Record<string, NavDirection>
|
|
||||||
)[e.code];
|
|
||||||
if (direction && splitActionsRef.current?.navigate(direction)) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||||
@@ -3134,6 +3126,9 @@ export function AppShell({
|
|||||||
{renderSidebarPanels(rightRailView, false)}
|
{renderSidebarPanels(rightRailView, false)}
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||||
|
<ComponentSlot slotId="shell.rightEdge" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,10 @@ interface OriginResolvableHost {
|
|||||||
* the desktop-wide default.
|
* the desktop-wide default.
|
||||||
*
|
*
|
||||||
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
||||||
* following the desktop-wide setting. Remote desktop passes it: it needs a
|
* following the desktop-wide setting. Remote desktop passes it when this
|
||||||
* guacd, which the desktop does not ship, so originating it here only works
|
* desktop is set to use an external guacd, so a host is not moved onto a
|
||||||
* once the user has pointed Termix at one of their own. Making that opt-in
|
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
|
||||||
* per host keeps an upgrade from moving working connections onto a guacd
|
* engine it runs locally like SSH.
|
||||||
* that isn't there -- see Termix-SSH/Support#1240.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveConnectionOrigin(
|
export async function resolveConnectionOrigin(
|
||||||
host: OriginResolvableHost,
|
host: OriginResolvableHost,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getBasePath } from "./base-path";
|
import { getBasePath } from "./base-path";
|
||||||
|
import { localBackendPort } from "./electron";
|
||||||
|
|
||||||
type BrowserLocation = Pick<
|
type BrowserLocation = Pick<
|
||||||
Location,
|
Location,
|
||||||
@@ -16,7 +17,8 @@ export function getDatabaseTransferUrl(
|
|||||||
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
|
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
|
||||||
): string {
|
): string {
|
||||||
if (electron) {
|
if (electron) {
|
||||||
const serverUrl = configuredServerUrl || "http://localhost:30001";
|
const serverUrl =
|
||||||
|
configuredServerUrl || `http://localhost:${localBackendPort()}`;
|
||||||
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ type ElectronWindow = Window &
|
|||||||
IS_ELECTRON?: boolean;
|
IS_ELECTRON?: boolean;
|
||||||
electronAPI?: {
|
electronAPI?: {
|
||||||
isElectron?: boolean;
|
isElectron?: boolean;
|
||||||
|
backendPort?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,3 +17,20 @@ export function isElectron(): boolean {
|
|||||||
|
|
||||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port of the desktop app's embedded backend. It moves off 30001 when
|
||||||
|
* another program holds that port.
|
||||||
|
*/
|
||||||
|
export function localBackendPort(): number {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
|
||||||
|
const port = (window as ElectronWindow).electronAPI?.backendPort;
|
||||||
|
return typeof port === "number" &&
|
||||||
|
Number.isInteger(port) &&
|
||||||
|
port > 0 &&
|
||||||
|
port < 65536
|
||||||
|
? port
|
||||||
|
: DEFAULT_LOCAL_BACKEND_PORT;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { isMacPlatform } from "./tab-jump-hotkey";
|
||||||
|
|
||||||
|
export type PaneNavDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
|
const ARROWS: Record<string, PaneNavDirection> = {
|
||||||
|
ArrowLeft: "left",
|
||||||
|
ArrowRight: "right",
|
||||||
|
ArrowUp: "up",
|
||||||
|
ArrowDown: "down",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The split pane to move to for this key, if any. Cmd+Option+Arrow on macOS,
|
||||||
|
* where Option+Arrow moves the shell cursor by word, Alt+Arrow elsewhere.
|
||||||
|
*/
|
||||||
|
export function getPaneNavDirection(e: KeyboardEvent): PaneNavDirection | null {
|
||||||
|
const direction = ARROWS[e.code];
|
||||||
|
if (!direction || e.ctrlKey || e.shiftKey || !e.altKey) return null;
|
||||||
|
if (e.metaKey !== isMacPlatform()) return null;
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paneNavHotkeyLabel(): string {
|
||||||
|
return isMacPlatform() ? "Cmd+Option+Arrows" : "Alt+Arrows";
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a plugin's i18n keys resolve from.
|
* Where a plugin's i18n keys resolve from.
|
||||||
*
|
*
|
||||||
@@ -19,3 +21,25 @@ export function pluginKey(
|
|||||||
if (!key) return key;
|
if (!key) return key;
|
||||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin's name in the UI language: "plugin.name" from its own locales,
|
||||||
|
* else the manifest's. A plugin whose strings are not loaded, like one only
|
||||||
|
* listed in a registry, shows the manifest name.
|
||||||
|
*/
|
||||||
|
export function pluginName(
|
||||||
|
t: TFunction,
|
||||||
|
plugin: { id: string; name: string },
|
||||||
|
): string {
|
||||||
|
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as pluginName, for "plugin.description". */
|
||||||
|
export function pluginDescription(
|
||||||
|
t: TFunction,
|
||||||
|
plugin: { id: string; description?: string | null },
|
||||||
|
): string {
|
||||||
|
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||||
|
defaultValue: plugin.description ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { getBasePath } from "@/lib/base-path";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||||
import { getDeviceId } from "@/lib/device-id";
|
import { getDeviceId } from "@/lib/device-id";
|
||||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||||
import {
|
import {
|
||||||
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
|
|||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
return buildOriginWsUrl({
|
return buildOriginWsUrl({
|
||||||
origin: options.origin ?? "local",
|
origin: options.origin ?? "local",
|
||||||
localPort: BACKEND_PORT,
|
localPort: localBackendPort(),
|
||||||
localPath: route,
|
localPath: route,
|
||||||
remotePath: route,
|
remotePath: route,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
|
||||||
|
* waits in sessionStorage, which survives a redirect login in the same tab,
|
||||||
|
* so no return path is ever sent to the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
|
||||||
|
|
||||||
|
/** The value as a same-origin path with a `view` parameter, or null. */
|
||||||
|
export function toViewLinkPath(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value, window.location.origin);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.origin !== window.location.origin) return null;
|
||||||
|
if (!url.searchParams.get("view")) return null;
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberViewLink(): void {
|
||||||
|
const { pathname, search, hash } = window.location;
|
||||||
|
const path = toViewLinkPath(`${pathname}${search}${hash}`);
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
|
||||||
|
} catch {
|
||||||
|
// Without storage the user just lands on the app after signing in.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The remembered link, cleared whether or not it is still valid. */
|
||||||
|
export function consumeViewLink(): string | null {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
|
||||||
|
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
|
||||||
|
return toViewLinkPath(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the remembered link, if there is one. The navigation waits a tick so
|
||||||
|
* history updates the login callback makes right after sign-in cannot cancel
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export function resumeViewLink(): boolean {
|
||||||
|
const path = consumeViewLink();
|
||||||
|
if (!path) return false;
|
||||||
|
setTimeout(() => window.location.replace(path), 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import axios, {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { getBasePath } from "@/lib/base-path";
|
import { getBasePath } from "@/lib/base-path";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||||
import { clearTermixSessionStorage } from "@/shell/TabContext";
|
import { clearTermixSessionStorage } from "@/shell/TabContext";
|
||||||
import type { SSHHost } from "@/types/index";
|
import type { SSHHost } from "@/types/index";
|
||||||
|
|
||||||
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
|
|||||||
if (electronMode) {
|
if (electronMode) {
|
||||||
// The desktop app always talks to its embedded backend. The server it
|
// The desktop app always talks to its embedded backend. The server it
|
||||||
// may be linked to is reached through createRemoteOriginApiInstance.
|
// may be linked to is reached through createRemoteOriginApiInstance.
|
||||||
return `http://localhost:${defaultPort}${path}`;
|
return `http://localhost:${localBackendPort()}${path}`;
|
||||||
} else if (devMode) {
|
} else if (devMode) {
|
||||||
if (!import.meta.env.VITE_API_HOST) {
|
if (!import.meta.env.VITE_API_HOST) {
|
||||||
return `/__termix_api/${defaultPort}${path}`;
|
return `/__termix_api/${defaultPort}${path}`;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
recommendedChoices,
|
recommendedChoices,
|
||||||
} from "../plugin-choices";
|
} from "../plugin-choices";
|
||||||
import { DocsLink } from "@/components/docs-link";
|
import { DocsLink } from "@/components/docs-link";
|
||||||
|
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||||
|
|
||||||
const CATEGORY_ORDER = [
|
const CATEGORY_ORDER = [
|
||||||
"Terminal",
|
"Terminal",
|
||||||
@@ -79,8 +80,8 @@ export function PluginsStep({
|
|||||||
}, [list, choices]);
|
}, [list, choices]);
|
||||||
|
|
||||||
const names = useMemo(
|
const names = useMemo(
|
||||||
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
|
() => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
|
||||||
[list],
|
[list, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const changed = useMemo(() => {
|
const changed = useMemo(() => {
|
||||||
@@ -260,7 +261,7 @@ export function PluginsStep({
|
|||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||||
{plugin.name}
|
{pluginName(t, plugin)}
|
||||||
{plugin.recommended && (
|
{plugin.recommended && (
|
||||||
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
|
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
|
||||||
{t("onboarding.pluginsRecommended")}
|
{t("onboarding.pluginsRecommended")}
|
||||||
@@ -268,7 +269,7 @@ export function PluginsStep({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||||
{plugin.description}
|
{pluginDescription(t, plugin)}
|
||||||
</span>
|
</span>
|
||||||
{forcedBy.has(plugin.id) && (
|
{forcedBy.has(plugin.id) && (
|
||||||
<span className="text-[10px] text-accent-brand">
|
<span className="text-[10px] text-accent-brand">
|
||||||
@@ -281,7 +282,7 @@ export function PluginsStep({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={plugin.name}
|
aria-label={pluginName(t, plugin)}
|
||||||
className="flex shrink-0 border border-border"
|
className="flex shrink-0 border border-border"
|
||||||
>
|
>
|
||||||
{CHOICES.map((option) => (
|
{CHOICES.map((option) => (
|
||||||
@@ -335,9 +336,11 @@ export function PluginsStep({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs font-medium">{plugin.name}</span>
|
<span className="text-xs font-medium">
|
||||||
|
{pluginName(t, plugin)}
|
||||||
|
</span>
|
||||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||||
{plugin.description}
|
{pluginDescription(t, plugin)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground/80">
|
<span className="text-[10px] text-muted-foreground/80">
|
||||||
{t("onboarding.pluginsConsentNote")}
|
{t("onboarding.pluginsConsentNote")}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { usePermissions } from "@/hooks/use-permissions";
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||||
import { usePluginStore } from "./plugin-store";
|
import { usePluginStore } from "./plugin-store";
|
||||||
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
|
||||||
@@ -27,7 +28,7 @@ export function PluginViewPlaceholder({
|
|||||||
usePluginStore();
|
usePluginStore();
|
||||||
const { has } = usePermissions();
|
const { has } = usePermissions();
|
||||||
const { status, owner } = unregisteredViewStatus(kind, viewId);
|
const { status, owner } = unregisteredViewStatus(kind, viewId);
|
||||||
const name = owner?.summary.name ?? viewId;
|
const name = owner ? pluginName(t, owner.summary) : viewId;
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { scopeHostFields } from "./host-scope";
|
import { scopeHostFields } from "./host-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { getPluginRecord } from "./plugin-store";
|
import { getPluginRecord } from "./plugin-store";
|
||||||
|
|
||||||
const PluginScopeContext = createContext<string | null>(null);
|
const PluginScopeContext = createContext<string | null>(null);
|
||||||
@@ -27,7 +28,8 @@ function CrashNotice({
|
|||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
|
const summary = getPluginRecord(pluginId)?.summary;
|
||||||
|
const name = summary ? pluginName(t, summary) : pluginId;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
||||||
<span>{t("plugins.runtime.crashed", { name })}</span>
|
<span>{t("plugins.runtime.crashed", { name })}</span>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type RegistryListing,
|
type RegistryListing,
|
||||||
} from "@/api/plugins-api";
|
} from "@/api/plugins-api";
|
||||||
import { useConfirm } from "@/components/surface/surface-scope";
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
|
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||||
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
|
||||||
import type { ConsentRequest } from "./PluginConsentPrompt";
|
import type { ConsentRequest } from "./PluginConsentPrompt";
|
||||||
|
|
||||||
@@ -109,9 +110,16 @@ export function usePluginsManager() {
|
|||||||
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Named in the UI language once here, so lists, sorting, search, dialogs
|
||||||
|
// and toasts all show the same name.
|
||||||
const plugins = useMemo(
|
const plugins = useMemo(
|
||||||
() => mergePlugins(installed, registry?.plugins ?? null),
|
() =>
|
||||||
[installed, registry],
|
mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({
|
||||||
|
...plugin,
|
||||||
|
name: pluginName(t, plugin),
|
||||||
|
description: pluginDescription(t, plugin),
|
||||||
|
})),
|
||||||
|
[installed, registry, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { SectionCard } from "@/components/section-card";
|
import { SectionCard } from "@/components/section-card";
|
||||||
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
||||||
import { pluginKey } from "@/lib/plugin-i18n";
|
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import {
|
import {
|
||||||
getPluginAdminSettings,
|
getPluginAdminSettings,
|
||||||
@@ -68,9 +68,7 @@ export function FeatureSettingsSection({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
label={t(pluginKey(plugin.id, "plugin.name"), {
|
label={pluginName(t, plugin)}
|
||||||
defaultValue: plugin.name,
|
|
||||||
})}
|
|
||||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||||
open={open}
|
open={open}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
|
|||||||
{!running && (
|
{!running && (
|
||||||
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
{t("settings.featureUnavailable", {
|
{t("settings.featureUnavailable", {
|
||||||
name: t(pluginKey(plugin.id, "plugin.name"), {
|
name: pluginName(t, plugin),
|
||||||
defaultValue: plugin.name,
|
|
||||||
}),
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { SectionCard } from "@/components/section-card";
|
import { SectionCard } from "@/components/section-card";
|
||||||
import { DocsLink } from "@/components/docs-link";
|
import { DocsLink } from "@/components/docs-link";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { type PluginSummary } from "@/api/plugins-api";
|
import { type PluginSummary } from "@/api/plugins-api";
|
||||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||||
import { usePluginScope } from "@/plugin-host/scope";
|
import { usePluginScope } from "@/plugin-host/scope";
|
||||||
@@ -141,13 +142,15 @@ function HostPluginSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={plugin.name}
|
title={pluginName(t, plugin)}
|
||||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||||
action={<DocsLink href={plugin.docs} />}
|
action={<DocsLink href={plugin.docs} />}
|
||||||
>
|
>
|
||||||
{!running && (
|
{!running && (
|
||||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
{t("settings.featureUnavailable", {
|
||||||
|
name: pluginName(t, plugin),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { pluginKey } from "@/lib/plugin-i18n";
|
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import type { UserPreferences } from "@/main-axios";
|
import type { UserPreferences } from "@/main-axios";
|
||||||
@@ -121,9 +121,7 @@ export function SettingsTab({
|
|||||||
id: `plugin:${plugin.id}`,
|
id: `plugin:${plugin.id}`,
|
||||||
band: "plugins",
|
band: "plugins",
|
||||||
labelKey: pluginKey(plugin.id, "plugin.name"),
|
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||||
label: t(pluginKey(plugin.id, "plugin.name"), {
|
label: pluginName(t, plugin),
|
||||||
defaultValue: plugin.name,
|
|
||||||
}),
|
|
||||||
icon: SettingsIcon,
|
icon: SettingsIcon,
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
searchKeys: fields.flatMap((field) =>
|
searchKeys: fields.flatMap((field) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
|
|||||||
import { isPrerelease } from "@/plugins/plugin-model";
|
import { isPrerelease } from "@/plugins/plugin-model";
|
||||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||||
import { docsUrl } from "@/lib/docs";
|
import { docsUrl } from "@/lib/docs";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
|
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
|
||||||
|
|
||||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||||
@@ -262,7 +263,7 @@ function InstanceUpdates() {
|
|||||||
{betaPlugins.map((plugin) => (
|
{betaPlugins.map((plugin) => (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={plugin.id}
|
key={plugin.id}
|
||||||
label={plugin.name}
|
label={pluginName(t, plugin)}
|
||||||
description={
|
description={
|
||||||
plugin.channel === "beta"
|
plugin.channel === "beta"
|
||||||
? `v${plugin.version}`
|
? `v${plugin.version}`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
|
import { pluginKey } from "@/lib/plugin-i18n";
|
||||||
import {
|
import {
|
||||||
hostEditorSectionList,
|
hostEditorSectionList,
|
||||||
registerHostEditorSection,
|
registerHostEditorSection,
|
||||||
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
|||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
group: placement?.group ?? host.editorGroup ?? "top",
|
group: placement?.group ?? host.editorGroup ?? "top",
|
||||||
order: placement?.order ?? host.editorOrder ?? 100,
|
order: placement?.order ?? host.editorOrder ?? 100,
|
||||||
labelKey: plugin.name,
|
labelKey: pluginKey(plugin.id, "plugin.name"),
|
||||||
label: plugin.name,
|
label: plugin.name,
|
||||||
// Nothing but the plugin's host settings, so it works for defaults too.
|
// Nothing but the plugin's host settings, so it works for defaults too.
|
||||||
defaults: ownSections.has(plugin.id) ? "only" : true,
|
defaults: ownSections.has(plugin.id) ? "only" : true,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type SplitPresetId,
|
type SplitPresetId,
|
||||||
} from "./split-presets";
|
} from "./split-presets";
|
||||||
import { MAX_PANES, type PaneEdge } from "./split-tree";
|
import { MAX_PANES, type PaneEdge } from "./split-tree";
|
||||||
|
import { paneNavHotkeyLabel } from "@/lib/pane-nav-hotkey";
|
||||||
|
|
||||||
function NodeThumb({ node }: { node: LayoutNode }) {
|
function NodeThumb({ node }: { node: LayoutNode }) {
|
||||||
if (node.kind === "pane") {
|
if (node.kind === "pane") {
|
||||||
@@ -129,7 +130,7 @@ export function SplitLayoutMenu({
|
|||||||
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span>{t("splitScreen.shortcutNavigate")}</span>
|
<span>{t("splitScreen.shortcutNavigate")}</span>
|
||||||
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd>
|
<kbd className="font-mono text-[10px]">{paneNavHotkeyLabel()}</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span>{t("splitScreen.zoom")}</span>
|
<span>{t("splitScreen.zoom")}</span>
|
||||||
|
|||||||
@@ -730,7 +730,7 @@ export function HostEditor({
|
|||||||
<EditorSection
|
<EditorSection
|
||||||
key={id}
|
key={id}
|
||||||
id={id}
|
id={id}
|
||||||
label={section.label ?? t(section.labelKey)}
|
label={t(section.labelKey, { defaultValue: section.label })}
|
||||||
>
|
>
|
||||||
<LazySection>
|
<LazySection>
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
|
|||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
group: "top" | "ssh";
|
group: "top" | "ssh";
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
/** Already-translated label, used instead of labelKey when set. */
|
/** Shown when labelKey has no translation. */
|
||||||
label?: string;
|
label?: string;
|
||||||
icon?: ComponentType<{ className?: string }>;
|
icon?: ComponentType<{ className?: string }>;
|
||||||
/** Position among the group's tabs, core ones included. */
|
/** Position among the group's tabs, core ones included. */
|
||||||
@@ -88,7 +88,7 @@ function sectionVisible(
|
|||||||
function mergeByOrder(
|
function mergeByOrder(
|
||||||
core: (HostTab & { order: number })[],
|
core: (HostTab & { order: number })[],
|
||||||
group: "top" | "ssh",
|
group: "top" | "ssh",
|
||||||
t: (key: string) => string,
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
protocols: Record<string, boolean> | undefined,
|
protocols: Record<string, boolean> | undefined,
|
||||||
defaultsMode = false,
|
defaultsMode = false,
|
||||||
): HostTab[] {
|
): HostTab[] {
|
||||||
@@ -104,7 +104,7 @@ function mergeByOrder(
|
|||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
return {
|
return {
|
||||||
id: section.id,
|
id: section.id,
|
||||||
label: section.label ?? t(section.labelKey),
|
label: t(section.labelKey, { defaultValue: section.label }),
|
||||||
icon: Icon ? <Icon className="size-3" /> : null,
|
icon: Icon ? <Icon className="size-3" /> : null,
|
||||||
order: section.order ?? 1000,
|
order: section.order ?? 1000,
|
||||||
};
|
};
|
||||||
@@ -119,7 +119,7 @@ function mergeByOrder(
|
|||||||
* `visible`; without, every registered top tab is listed.
|
* `visible`; without, every registered top tab is listed.
|
||||||
*/
|
*/
|
||||||
export function makeHostTabs(
|
export function makeHostTabs(
|
||||||
t: (key: string) => string,
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
protocols?: Record<string, boolean>,
|
protocols?: Record<string, boolean>,
|
||||||
defaultsMode = false,
|
defaultsMode = false,
|
||||||
): HostTab[] {
|
): HostTab[] {
|
||||||
@@ -146,7 +146,7 @@ export function makeHostTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeHostSshSubTabs(
|
export function makeHostSshSubTabs(
|
||||||
t: (key: string) => string,
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
protocols?: Record<string, boolean>,
|
protocols?: Record<string, boolean>,
|
||||||
defaultsMode = false,
|
defaultsMode = false,
|
||||||
): HostTab[] {
|
): HostTab[] {
|
||||||
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function makeCredentialTabs(
|
export function makeCredentialTabs(
|
||||||
t: (key: string) => string,
|
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||||
): CredentialTab[] {
|
): CredentialTab[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
|||||||
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||||
import { withLiveStatus } from "@/sidebar/live-host-status";
|
import { withLiveStatus } from "@/sidebar/live-host-status";
|
||||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
|
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
|
||||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||||
import type {
|
import type {
|
||||||
@@ -816,7 +817,7 @@ export function HostsPanel({
|
|||||||
}
|
}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{plugin.name}
|
{pluginName(t, plugin)}
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
import {
|
import {
|
||||||
UI_AREA_KEYS,
|
UI_AREA_KEYS,
|
||||||
@@ -67,9 +68,10 @@ export function InterfacePresetSettings({
|
|||||||
)
|
)
|
||||||
.map((area) => {
|
.map((area) => {
|
||||||
const pluginId = area.slice("plugin:".length);
|
const pluginId = area.slice("plugin:".length);
|
||||||
|
const summary = pluginStore.records.get(pluginId)?.summary;
|
||||||
return {
|
return {
|
||||||
key: area,
|
key: area,
|
||||||
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
|
label: summary ? pluginName(t, summary) : pluginId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return [...core, ...plugins];
|
return [...core, ...plugins];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||||
import { resetHostToDefaults } from "@/api/host-defaults-api";
|
import { resetHostToDefaults } from "@/api/host-defaults-api";
|
||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
|
import { pluginName } from "@/lib/plugin-i18n";
|
||||||
import { usePluginHostSections } from "@/settings/HostPluginSections";
|
import { usePluginHostSections } from "@/settings/HostPluginSections";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
@@ -1284,7 +1285,7 @@ export function SidebarTree({
|
|||||||
value
|
value
|
||||||
? "hosts.enablePluginFeature"
|
? "hosts.enablePluginFeature"
|
||||||
: "hosts.disablePluginFeature",
|
: "hosts.disablePluginFeature",
|
||||||
{ name: plugin.name },
|
{ name: pluginName(t, plugin) },
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)),
|
)),
|
||||||
@@ -1307,7 +1308,7 @@ export function SidebarTree({
|
|||||||
{ id: "core", label: t("hostDefaults.resetCore") },
|
{ id: "core", label: t("hostDefaults.resetCore") },
|
||||||
...hostSettingPlugins.map((plugin) => ({
|
...hostSettingPlugins.map((plugin) => ({
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
label: plugin.name,
|
label: pluginName(t, plugin),
|
||||||
})),
|
})),
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { localBackendPort } from "@/lib/electron";
|
||||||
|
|
||||||
|
function setElectronAPI(api: unknown) {
|
||||||
|
(window as unknown as { electronAPI?: unknown }).electronAPI = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("localBackendPort", () => {
|
||||||
|
it("uses the port the desktop app picked", () => {
|
||||||
|
setElectronAPI({ backendPort: 41234 });
|
||||||
|
expect(localBackendPort()).toBe(41234);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 30001", () => {
|
||||||
|
expect(localBackendPort()).toBe(30001);
|
||||||
|
setElectronAPI({ backendPort: Number.NaN });
|
||||||
|
expect(localBackendPort()).toBe(30001);
|
||||||
|
setElectronAPI({ backendPort: 70000 });
|
||||||
|
expect(localBackendPort()).toBe(30001);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
getPaneNavDirection,
|
||||||
|
paneNavHotkeyLabel,
|
||||||
|
} from "../../lib/pane-nav-hotkey";
|
||||||
|
|
||||||
|
function keyEvent(init: Partial<KeyboardEvent>): KeyboardEvent {
|
||||||
|
return {
|
||||||
|
code: "ArrowLeft",
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...init,
|
||||||
|
} as KeyboardEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe("pane-nav-hotkey", () => {
|
||||||
|
it("uses Alt+Arrow outside macOS", () => {
|
||||||
|
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||||
|
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBe("left");
|
||||||
|
expect(
|
||||||
|
getPaneNavDirection(keyEvent({ code: "ArrowDown", altKey: true })),
|
||||||
|
).toBe("down");
|
||||||
|
expect(
|
||||||
|
getPaneNavDirection(keyEvent({ altKey: true, metaKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(paneNavHotkeyLabel()).toBe("Alt+Arrows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Option+Arrow to the shell on macOS", () => {
|
||||||
|
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||||
|
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBeNull();
|
||||||
|
expect(
|
||||||
|
getPaneNavDirection(
|
||||||
|
keyEvent({ code: "ArrowRight", altKey: true, metaKey: true }),
|
||||||
|
),
|
||||||
|
).toBe("right");
|
||||||
|
expect(paneNavHotkeyLabel()).toBe("Cmd+Option+Arrows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores other keys and modifiers", () => {
|
||||||
|
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||||
|
expect(
|
||||||
|
getPaneNavDirection(keyEvent({ code: "KeyA", altKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
getPaneNavDirection(keyEvent({ altKey: true, shiftKey: true })),
|
||||||
|
).toBeNull();
|
||||||
|
expect(getPaneNavDirection(keyEvent({}))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
|
||||||
|
|
||||||
|
async function zhCN() {
|
||||||
|
const i18n = i18next.createInstance();
|
||||||
|
await i18n.init({
|
||||||
|
lng: "zh-CN",
|
||||||
|
fallbackLng: "en",
|
||||||
|
fallbackNS: "translation",
|
||||||
|
resources: {
|
||||||
|
"zh-CN": {
|
||||||
|
translation: { common: { save: "保存" } },
|
||||||
|
"host-metrics": {
|
||||||
|
plugin: { name: "主机指标", description: "查看主机负载" },
|
||||||
|
},
|
||||||
|
"no-description": { plugin: { name: "无描述" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return i18n.t;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pluginName", () => {
|
||||||
|
it("reads the name from the plugin's own namespace", async () => {
|
||||||
|
const t = await zhCN();
|
||||||
|
expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe(
|
||||||
|
"主机指标",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the manifest name when the plugin has no strings", async () => {
|
||||||
|
const t = await zhCN();
|
||||||
|
expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe(
|
||||||
|
"Not Loaded",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pluginDescription", () => {
|
||||||
|
it("reads the description from the plugin's own namespace", async () => {
|
||||||
|
const t = await zhCN();
|
||||||
|
expect(
|
||||||
|
pluginDescription(t, {
|
||||||
|
id: "host-metrics",
|
||||||
|
description: "See host load",
|
||||||
|
}),
|
||||||
|
).toBe("查看主机负载");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the manifest description, or nothing", async () => {
|
||||||
|
const t = await zhCN();
|
||||||
|
expect(
|
||||||
|
pluginDescription(t, { id: "no-description", description: "Plain" }),
|
||||||
|
).toBe("Plain");
|
||||||
|
expect(pluginDescription(t, { id: "not-loaded" })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume";
|
||||||
|
|
||||||
|
describe("toViewLinkPath", () => {
|
||||||
|
it.each([
|
||||||
|
["/?view=example-plugin.request&request=123", true],
|
||||||
|
["/termix/?view=example-plugin.request#top", true],
|
||||||
|
["//evil.com/?view=x", false],
|
||||||
|
["/\\evil.com/?view=x", false],
|
||||||
|
["https://evil.com/?view=x", false],
|
||||||
|
["/foo", false],
|
||||||
|
["/?view=", false],
|
||||||
|
["not a url", false],
|
||||||
|
])("%s -> %s", (value, accepted) => {
|
||||||
|
expect(toViewLinkPath(value)).toBe(accepted ? value : null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("consumeViewLink", () => {
|
||||||
|
it("clears the stored link even when it is invalid", () => {
|
||||||
|
sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x");
|
||||||
|
expect(consumeViewLink()).toBeNull();
|
||||||
|
expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const KEY = "termix-pending-view-link";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
rendered: null as ReactNode,
|
||||||
|
isElectron: vi.fn(() => false),
|
||||||
|
getStoredAuth: vi.fn(),
|
||||||
|
getUserInfo: vi.fn(),
|
||||||
|
fetchGuestViews: vi.fn(),
|
||||||
|
startPluginRuntime: vi.fn(async () => {}),
|
||||||
|
standaloneViewFor: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-dom/client", () => ({
|
||||||
|
createRoot: () => ({
|
||||||
|
render: (node: ReactNode) => {
|
||||||
|
mocks.rendered = node;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/client-cache-version", () => ({
|
||||||
|
prepareClientCacheVersion: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/i18n/i18n", () => ({}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/theme-provider", () => ({
|
||||||
|
ThemeProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/BrandingContext", () => ({
|
||||||
|
BrandingProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/contexts/UiPreferencesContext", () => ({
|
||||||
|
UiPreferencesProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/sonner", () => ({ Toaster: () => null }));
|
||||||
|
vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} }));
|
||||||
|
vi.mock("@/lib/electron-wheel-zoom", () => ({
|
||||||
|
installElectronWheelZoomGuard: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron }));
|
||||||
|
vi.mock("@/lib/embedded-server-status", () => ({
|
||||||
|
getEmbeddedServerFailure: async () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/auth/Auth", () => ({
|
||||||
|
Auth: ({ onLogin }: { onLogin: (username: string) => void }) => (
|
||||||
|
<button onClick={() => onLogin("alice")}>sign in</button>
|
||||||
|
),
|
||||||
|
getStoredAuth: mocks.getStoredAuth,
|
||||||
|
clearStoredAuth: vi.fn(),
|
||||||
|
markDesktopManualLogout: vi.fn(),
|
||||||
|
clearDesktopManualLogout: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main-axios", () => ({
|
||||||
|
getUserInfo: mocks.getUserInfo,
|
||||||
|
getCurrentToken: vi.fn(async () => null),
|
||||||
|
appReadyPromise: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/plugin-host/loader", () => ({
|
||||||
|
fetchGuestViews: mocks.fetchGuestViews,
|
||||||
|
startPluginRuntime: mocks.startPluginRuntime,
|
||||||
|
stopPluginRuntime: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/plugin-host/plugin-store", () => ({
|
||||||
|
settledPromise: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() }));
|
||||||
|
vi.mock("@/hooks/use-permissions", () => ({
|
||||||
|
preloadPermissions: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/api/open-tabs-api", () => ({
|
||||||
|
getUserPreferences: async () => undefined,
|
||||||
|
}));
|
||||||
|
vi.mock("@/shell/tab-registry", () => ({
|
||||||
|
standaloneViewFor: mocks.standaloneViewFor,
|
||||||
|
}));
|
||||||
|
vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({
|
||||||
|
PluginViewPlaceholder: ({ viewId }: { viewId: string }) => (
|
||||||
|
<div>placeholder:{viewId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/onboarding/startup", () => ({
|
||||||
|
resolveStartupOnboarding: async () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/AppShell", () => ({ AppShell: () => <div>app shell</div> }));
|
||||||
|
vi.mock("@/onboarding/OnboardingStage", () => ({
|
||||||
|
OnboardingStage: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalLocation = window.location;
|
||||||
|
let replace: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
/** Loads the entry point fresh, the way a page load would. */
|
||||||
|
async function openPage(url: string) {
|
||||||
|
window.history.replaceState({}, "", url);
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.rendered = null;
|
||||||
|
await import("../../main");
|
||||||
|
await vi.waitFor(() => expect(mocks.rendered).not.toBeNull());
|
||||||
|
render(<>{mocks.rendered}</>);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
|
mocks.isElectron.mockReturnValue(false);
|
||||||
|
mocks.getStoredAuth.mockReturnValue(null);
|
||||||
|
mocks.getUserInfo.mockRejectedValue({ status: 401 });
|
||||||
|
mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]);
|
||||||
|
mocks.standaloneViewFor.mockImplementation((view: string) => ({
|
||||||
|
standalone: () => <div>standalone:{view}</div>,
|
||||||
|
}));
|
||||||
|
replace = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
get origin() {
|
||||||
|
return originalLocation.origin;
|
||||||
|
},
|
||||||
|
get pathname() {
|
||||||
|
return originalLocation.pathname;
|
||||||
|
},
|
||||||
|
get search() {
|
||||||
|
return originalLocation.search;
|
||||||
|
},
|
||||||
|
get hash() {
|
||||||
|
return originalLocation.hash;
|
||||||
|
},
|
||||||
|
replace,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("full-screen view links", () => {
|
||||||
|
it("shows the login when signed out and returns to the link after it", async () => {
|
||||||
|
await openPage("/?view=example-plugin.request&request=123");
|
||||||
|
|
||||||
|
const signIn = await screen.findByText("sign in");
|
||||||
|
expect(sessionStorage.getItem(KEY)).toBe(
|
||||||
|
"/?view=example-plugin.request&request=123",
|
||||||
|
);
|
||||||
|
fireEvent.click(signIn);
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(replace).toHaveBeenCalledWith(
|
||||||
|
"/?view=example-plugin.request&request=123",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bare view in the desktop app", async () => {
|
||||||
|
mocks.isElectron.mockReturnValue(true);
|
||||||
|
mocks.standaloneViewFor.mockReturnValue(undefined);
|
||||||
|
await openPage("/?view=example-plugin.request");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("placeholder:example-plugin.request"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.queryByText("sign in")).toBeNull();
|
||||||
|
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves a guest view without a session or a login", async () => {
|
||||||
|
await openPage("/?view=example-plugin.guest&room=1");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("standalone:example-plugin.guest"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(mocks.getUserInfo).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||||
import type { OnboardingPluginList } from "@/api/plugins-api";
|
import type { OnboardingPluginList } from "@/api/plugins-api";
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
@@ -186,4 +188,38 @@ describe("PluginsStep", () => {
|
|||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows plugin names and descriptions in the UI language", async () => {
|
||||||
|
const i18n = i18next.createInstance();
|
||||||
|
await i18n.use(initReactI18next).init({
|
||||||
|
lng: "zh-CN",
|
||||||
|
fallbackNS: "translation",
|
||||||
|
resources: {
|
||||||
|
"zh-CN": {
|
||||||
|
translation: {},
|
||||||
|
term: { plugin: { name: "终端", description: "终端插件" } },
|
||||||
|
stats: { plugin: { name: "统计" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<PluginsStep
|
||||||
|
mode="full"
|
||||||
|
isAdmin
|
||||||
|
isDesktop={false}
|
||||||
|
shellReady={false}
|
||||||
|
setCanContinue={() => {}}
|
||||||
|
setBeforeNext={() => {}}
|
||||||
|
/>
|
||||||
|
</I18nextProvider>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("终端")).toBeTruthy();
|
||||||
|
expect(screen.getByText("终端插件")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("统计")).toBeTruthy();
|
||||||
|
expect(screen.getByText("stats plugin")).toBeTruthy();
|
||||||
|
expect(screen.getByText("BASE")).toBeTruthy();
|
||||||
|
expect(screen.getByText("base plugin")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,9 +35,17 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
|||||||
vi.mock("@/components/surface/surface-scope", () => ({
|
vi.mock("@/components/surface/surface-scope", () => ({
|
||||||
useConfirm: () => vi.fn(async () => true),
|
useConfirm: () => vi.fn(async () => true),
|
||||||
}));
|
}));
|
||||||
vi.mock("react-i18next", () => ({
|
const i18n = vi.hoisted(() => {
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
const strings: Record<string, string> = {
|
||||||
}));
|
"sample-plugin:plugin.name": "示例",
|
||||||
|
"sample-plugin:plugin.description": "一个示例插件",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
t: (key: string, options?: { defaultValue?: string }) =>
|
||||||
|
strings[key] ?? options?.defaultValue ?? key,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("react-i18next", () => ({ useTranslation: () => i18n }));
|
||||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||||
|
|
||||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||||
@@ -69,3 +77,33 @@ it("waits for local consent and sends the reviewed version and capabilities", as
|
|||||||
{ version: "1.0.0", capabilities: ["kv:own"] },
|
{ version: "1.0.0", capabilities: ["kv:own"] },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names plugins in the UI language, falling back to the manifest", async () => {
|
||||||
|
api.getPluginRegistry.mockResolvedValueOnce({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "browse-only",
|
||||||
|
name: "Browse Only",
|
||||||
|
description: "Not installed",
|
||||||
|
author: "someone",
|
||||||
|
category: "Tools",
|
||||||
|
latestVersion: "1.0.0",
|
||||||
|
versions: [],
|
||||||
|
bundled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
managedByServer: true,
|
||||||
|
registry: {},
|
||||||
|
} as never);
|
||||||
|
const { result } = renderHook(() => usePluginsManager());
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(
|
||||||
|
result.current.plugins.map(({ name, description }) => ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "示例", description: "一个示例插件" },
|
||||||
|
{ name: "Browse Only", description: "Not installed" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
vi.mock("react-i18next", () => ({
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: { defaultValue?: string }) =>
|
||||||
|
options?.defaultValue ?? key,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||||
@@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => {
|
|||||||
"feature:docker",
|
"feature:docker",
|
||||||
"feature:warpgate",
|
"feature:warpgate",
|
||||||
]);
|
]);
|
||||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
|
expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
|
||||||
"Docker",
|
labelKey: "docker:plugin.name",
|
||||||
);
|
label: "Docker",
|
||||||
|
});
|
||||||
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user