mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
677c693c87 | ||
|
|
b9193be951 | ||
|
|
ee42837fc6 | ||
|
|
b63852ee5c | ||
|
|
3892381b95 | ||
|
|
4a9c434cd1 | ||
|
|
16f2f509f2 | ||
|
|
893e7e5a4b | ||
|
|
11f6910d51 | ||
|
|
6ccee9b336 | ||
|
|
49f1914e6e | ||
|
|
9949d0711e | ||
|
|
6bacb17390 | ||
|
|
65d10b37cf | ||
|
|
764d034098 | ||
|
|
fc77b6c756 | ||
|
|
f76a1adfb8 | ||
|
|
8e809aa760 | ||
|
|
7066215bd9 | ||
|
|
214372d104 | ||
|
|
bb9bca5303 | ||
|
|
cf62f108d2 | ||
|
|
5cc65d87cd | ||
|
|
e166994ec4 | ||
|
|
3a851ad189 | ||
|
|
d2851982a6 | ||
|
|
cc5dce18e2 | ||
|
|
6084ed4d3c | ||
|
|
c3f6db69e9 | ||
|
|
8e2221cb8f | ||
|
|
7324982f47 | ||
|
|
f20d64b4f2 | ||
|
|
aa0b32a142 | ||
|
|
c8a907538e | ||
|
|
dd5573cc10 | ||
|
|
071711dafc | ||
|
|
bc74fba3f5 | ||
|
|
694dc34b19 | ||
|
|
b3b08b4ff2 | ||
|
|
9b0af07607 | ||
|
|
c044dbde87 | ||
|
|
1396b3a70c | ||
|
|
4c9ee5923e | ||
|
|
25e58a6425 | ||
|
|
7d13b2a9fe | ||
|
|
e610aba0e5 | ||
|
|
3941dd4ecc | ||
|
|
872e5eba73 | ||
|
|
8c3f816407 | ||
|
|
13f7aa95a3 | ||
|
|
8e5a50623c | ||
|
|
d791e52d82 | ||
|
|
fb04edc497 |
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'
|
||||||
|
|||||||
@@ -1,5 +1,53 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.2
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
|
||||||
|
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
|
||||||
|
- The local terminal failed to start in the universal macOS build
|
||||||
|
- Hosts shared with you never showed up on a linked desktop
|
||||||
|
- A linked desktop could never turn on an unsigned plugin the server had enabled
|
||||||
|
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
|
||||||
|
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
|
||||||
|
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
|
||||||
|
- Usernames are now unique
|
||||||
|
- Importing user data keeps foreign keys on
|
||||||
|
- Trusted proxy login checks the address nginx saw, not nginx itself
|
||||||
|
- The desktop login frame only trusts the server's own origin
|
||||||
|
- External login URLs and callback ports are checked on the desktop app
|
||||||
|
- A changed jump host key is no longer accepted without asking
|
||||||
|
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
|
||||||
|
- Two connects to the same host at once could overwrite each other's connection
|
||||||
|
- Indonesian uses the same technical terms everywhere
|
||||||
|
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-1
@@ -74,12 +74,15 @@ COPY vendor ./vendor
|
|||||||
|
|
||||||
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
COPY scripts/patch-ssh2-agent.cjs ./scripts/
|
||||||
|
|
||||||
|
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
|
||||||
|
# OpenSSL instead (chacha20-poly1305 is then left out).
|
||||||
RUN npm ci --omit=dev --ignore-scripts && \
|
RUN npm ci --omit=dev --ignore-scripts && \
|
||||||
node scripts/patch-ssh2-agent.cjs && \
|
node scripts/patch-ssh2-agent.cjs && \
|
||||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||||
npm rebuild bcryptjs ssh2 && \
|
npm rebuild bcryptjs && \
|
||||||
|
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final optimized image
|
# Stage 6: Final optimized image
|
||||||
|
|||||||
+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.
|
||||||
@@ -28,21 +22,14 @@ services:
|
|||||||
# Trusted reverse-proxy authentication is disabled by default. When
|
# Trusted reverse-proxy authentication is disabled by default. When
|
||||||
# enabled, do not expose this container directly to untrusted clients.
|
# enabled, do not expose this container directly to untrusted clients.
|
||||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||||
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
|
# List the address your authenticating proxy connects from, as this
|
||||||
|
# container sees it (for a proxy container on termix-net, its IP there).
|
||||||
|
# Keep it narrow: published ports can reach the container from the
|
||||||
|
# Docker gateway, so do not trust whole private ranges.
|
||||||
|
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||||
|
# X-Forwarded-For; trusted proxy login checks this address.
|
||||||
|
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
@@ -473,9 +476,12 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
# Only nginx's own errors land here (the backend is down or too slow).
|
||||||
|
# There is no 50x page, so answer directly instead of a 404.
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /app/html;
|
|
||||||
internal;
|
internal;
|
||||||
|
default_type application/json;
|
||||||
|
return 502 '{"error":"The Termix server is not responding"}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-1
@@ -169,6 +169,9 @@ http {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# The TCP peer before set_real_ip_from rewrites $remote_addr from
|
||||||
|
# X-Forwarded-For; trusted proxy login checks this address.
|
||||||
|
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
@@ -442,9 +445,12 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
# Only nginx's own errors land here (the backend is down or too slow).
|
||||||
|
# There is no 50x page, so answer directly instead of a 404.
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /app/html;
|
|
||||||
internal;
|
internal;
|
||||||
|
default_type application/json;
|
||||||
|
return 502 '{"error":"The Termix server is not responding"}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
|
||||||
|
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
|
||||||
|
WHERE ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,13 @@
|
|||||||
"when": 1791755801066,
|
"when": 1791755801066,
|
||||||
"tag": "0071_drop_install_count_source",
|
"tag": "0071_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791935283211,
|
||||||
|
"tag": "0072_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -505,6 +505,13 @@
|
|||||||
"when": 1791755800419,
|
"when": 1791755800419,
|
||||||
"tag": "0071_drop_install_count_source",
|
"tag": "0071_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791935282340,
|
||||||
|
"tag": "0072_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -470,6 +470,13 @@
|
|||||||
"when": 1791755799726,
|
"when": 1791755799726,
|
||||||
"tag": "0066_drop_install_count_source",
|
"tag": "0066_drop_install_count_source",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 67,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791935281343,
|
||||||
|
"tag": "0067_unique_username",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||||
|
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||||
|
|
||||||
|
// Extensions the OS would run rather than open in an editor. With no editor
|
||||||
|
// configured the temp copy goes to shell.openPath, and its name comes from
|
||||||
|
// the remote server.
|
||||||
|
const LAUNCHABLE_EXTENSIONS = new Set([
|
||||||
|
// Windows
|
||||||
|
"application",
|
||||||
|
"appref-ms",
|
||||||
|
"bat",
|
||||||
|
"chm",
|
||||||
|
"cmd",
|
||||||
|
"com",
|
||||||
|
"cpl",
|
||||||
|
"exe",
|
||||||
|
"gadget",
|
||||||
|
"hta",
|
||||||
|
"inf",
|
||||||
|
"jar",
|
||||||
|
"js",
|
||||||
|
"jse",
|
||||||
|
"library-ms",
|
||||||
|
"lnk",
|
||||||
|
"msc",
|
||||||
|
"msi",
|
||||||
|
"msp",
|
||||||
|
"mst",
|
||||||
|
"pif",
|
||||||
|
"ps1",
|
||||||
|
"psd1",
|
||||||
|
"psm1",
|
||||||
|
"py",
|
||||||
|
"pyw",
|
||||||
|
"reg",
|
||||||
|
"scf",
|
||||||
|
"scr",
|
||||||
|
"search-ms",
|
||||||
|
"settingcontent-ms",
|
||||||
|
"url",
|
||||||
|
"vbe",
|
||||||
|
"vbs",
|
||||||
|
"ws",
|
||||||
|
"wsf",
|
||||||
|
"wsh",
|
||||||
|
"xll",
|
||||||
|
// macOS
|
||||||
|
"app",
|
||||||
|
"command",
|
||||||
|
"pkg",
|
||||||
|
"terminal",
|
||||||
|
"tool",
|
||||||
|
"workflow",
|
||||||
|
// Linux
|
||||||
|
"appimage",
|
||||||
|
"desktop",
|
||||||
|
"run",
|
||||||
|
"sh",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLaunchableFileName(fileName) {
|
||||||
|
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
||||||
|
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
||||||
|
const extension = path.extname(name).slice(1).toLowerCase();
|
||||||
|
return LAUNCHABLE_EXTENSIONS.has(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* The login URL a server hands the desktop app goes to shell.openExternal,
|
||||||
|
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
|
||||||
|
* would launch whatever OS handler is registered for it.
|
||||||
|
*/
|
||||||
|
function isExternalLoginUrl(value) {
|
||||||
|
try {
|
||||||
|
const { protocol } = new URL(value);
|
||||||
|
return protocol === "https:" || protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A non-privileged port for the loopback callback listener. */
|
||||||
|
function isCallbackPort(value) {
|
||||||
|
return Number.isInteger(value) && value >= 1024 && value <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isExternalLoginUrl, isCallbackPort };
|
||||||
@@ -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,7 +39,11 @@ function resolveLocalShell(
|
|||||||
* how many helpers were fixed.
|
* how many helpers were fixed.
|
||||||
*/
|
*/
|
||||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
||||||
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
|
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
||||||
|
const dir = ptyDir.replace(
|
||||||
|
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
||||||
|
"$1.asar.unpacked",
|
||||||
|
);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(dir, "build", "Release", "spawn-helper"),
|
path.join(dir, "build", "Release", "spawn-helper"),
|
||||||
path.join(
|
path.join(
|
||||||
|
|||||||
+93
-115
@@ -35,7 +35,15 @@ 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 {
|
||||||
|
EXTERNAL_EDITOR_SETTING,
|
||||||
|
isLaunchableFileName,
|
||||||
|
} = require("./external-editor.cjs");
|
||||||
|
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||||
|
|
||||||
const localTerminalSessions = new Map();
|
const localTerminalSessions = new Map();
|
||||||
|
|
||||||
@@ -434,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" ||
|
||||||
@@ -485,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) {
|
||||||
@@ -700,6 +700,9 @@ let tray = null;
|
|||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
const tempFiles = new Map();
|
const tempFiles = new Map();
|
||||||
const externalEditorSessions = new Map();
|
const externalEditorSessions = new Map();
|
||||||
|
// Paths the user picked in an open dialog this session. Only these may be
|
||||||
|
// saved as the external editor, so the renderer cannot name an executable.
|
||||||
|
const dialogChosenPaths = new Set();
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||||
@@ -1116,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;
|
||||||
@@ -1156,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"],
|
||||||
@@ -1421,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,
|
||||||
@@ -1663,6 +1697,12 @@ ipcMain.handle("get-embedded-server-status", () => {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"external-browser-login",
|
"external-browser-login",
|
||||||
async (_event, authUrl, callbackPort) => {
|
async (_event, authUrl, callbackPort) => {
|
||||||
|
if (!isExternalLoginUrl(authUrl)) {
|
||||||
|
return { success: false, error: "Invalid authentication URL" };
|
||||||
|
}
|
||||||
|
if (!isCallbackPort(callbackPort)) {
|
||||||
|
return { success: false, error: "Invalid callback port" };
|
||||||
|
}
|
||||||
const http = require("http");
|
const http = require("http");
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -2375,6 +2415,8 @@ async function testC2SRelay(
|
|||||||
if (runtime) runtime.authSocket = ws;
|
if (runtime) runtime.authSocket = ws;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
// The relay sends its reason and then closes; keep the reason.
|
||||||
|
let relayError = null;
|
||||||
const settle = (result) => {
|
const settle = (result) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -2411,6 +2453,10 @@ async function testC2SRelay(
|
|||||||
settle({ success: true });
|
settle({ success: true });
|
||||||
} else if (message.type === "error") {
|
} else if (message.type === "error") {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
relayError = normalizeC2SErrorMessage(
|
||||||
|
message.error,
|
||||||
|
"Tunnel test failed",
|
||||||
|
);
|
||||||
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2424,7 +2470,10 @@ async function testC2SRelay(
|
|||||||
});
|
});
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
||||||
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
setC2STunnelError(
|
||||||
|
tunnel.name,
|
||||||
|
relayError || "Endpoint SSH connection closed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
settle({ success: false, error: "Tunnel test connection closed" });
|
settle({ success: false, error: "Tunnel test connection closed" });
|
||||||
@@ -3156,18 +3205,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
|||||||
return `[${date}] ${computerName} (${platform} ${release})`;
|
return `[${date}] ${computerName} (${platform} ${release})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function readSetting(key) {
|
||||||
|
const settingsPath = path.join(app.getPath("userData"), "settings.json");
|
||||||
|
if (!fs.existsSync(settingsPath)) return null;
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
||||||
|
return settings[key] !== undefined ? settings[key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle("get-setting", (event, key) => {
|
ipcMain.handle("get-setting", (event, key) => {
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath("userData");
|
return readSetting(key);
|
||||||
const settingsPath = path.join(userDataPath, "settings.json");
|
|
||||||
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
||||||
const settings = JSON.parse(settingsData);
|
|
||||||
return settings[key] !== undefined ? settings[key] : null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading setting:", error);
|
console.error("Error reading setting:", error);
|
||||||
return null;
|
return null;
|
||||||
@@ -3175,6 +3222,16 @@ ipcMain.handle("get-setting", (event, key) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("set-setting", (event, key, value) => {
|
ipcMain.handle("set-setting", (event, key, value) => {
|
||||||
|
if (typeof key !== "string") {
|
||||||
|
return { success: false, error: "Invalid setting key" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === EXTERNAL_EDITOR_SETTING &&
|
||||||
|
value &&
|
||||||
|
!dialogChosenPaths.has(value)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Choose the editor in the file dialog" };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const userDataPath = app.getPath("userData");
|
const userDataPath = app.getPath("userData");
|
||||||
const settingsPath = path.join(userDataPath, "settings.json");
|
const settingsPath = path.join(userDataPath, "settings.json");
|
||||||
@@ -3198,94 +3255,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
|
||||||
try {
|
|
||||||
const ses = mainWindow?.webContents?.session;
|
|
||||||
if (!ses) return null;
|
|
||||||
const cookies = await ses.cookies.get({
|
|
||||||
name,
|
|
||||||
...(targetUrl ? { url: targetUrl } : {}),
|
|
||||||
});
|
|
||||||
const cookie = cookies.find((candidate) =>
|
|
||||||
cookieMatchesUrl(candidate, targetUrl),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
cookie?.value ||
|
|
||||||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get session cookie:", error);
|
|
||||||
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function cookieMatchesUrl(cookie, targetUrl) {
|
|
||||||
if (!targetUrl) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const targetHost = new URL(targetUrl).hostname;
|
|
||||||
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
cookieDomain === targetHost ||
|
|
||||||
targetHost.endsWith(`.${cookieDomain}`) ||
|
|
||||||
(!cookieDomain && targetHost === "localhost")
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle(
|
|
||||||
"wait-session-cookie",
|
|
||||||
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
|
||||||
const ses = mainWindow?.webContents?.session;
|
|
||||||
if (!ses) return { success: false, error: "No Electron session" };
|
|
||||||
|
|
||||||
const existingCookies = await ses.cookies.get({
|
|
||||||
name,
|
|
||||||
...(targetUrl ? { url: targetUrl } : {}),
|
|
||||||
});
|
|
||||||
const existingCookie = existingCookies.find((cookie) =>
|
|
||||||
cookieMatchesUrl(cookie, targetUrl),
|
|
||||||
);
|
|
||||||
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
|
||||||
return { success: true, value: existingCookie.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
|
||||||
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
|
||||||
return { success: true, value: rememberedCookie.value };
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
ses.cookies.off("changed", onCookieChanged);
|
|
||||||
resolve({ success: false, error: "Timed out waiting for cookie" });
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
function onCookieChanged(_event, cookie, _cause, removed) {
|
|
||||||
if (
|
|
||||||
removed ||
|
|
||||||
cookie.name !== name ||
|
|
||||||
!cookie.value ||
|
|
||||||
cookie.value === previousValue ||
|
|
||||||
!cookieMatchesUrl(cookie, targetUrl)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
ses.cookies.off("changed", onCookieChanged);
|
|
||||||
resolve({ success: true, value: cookie.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
ses.cookies.on("changed", onCookieChanged);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle("clear-session-cookies", async () => {
|
ipcMain.handle("clear-session-cookies", async () => {
|
||||||
try {
|
try {
|
||||||
clearPersistedElectronAuthCookies();
|
clearPersistedElectronAuthCookies();
|
||||||
@@ -3413,7 +3382,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
||||||
return dialog.showOpenDialog(mainWindow, options || {});
|
const result = await dialog.showOpenDialog(mainWindow, options || {});
|
||||||
|
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function toLocalFileType(stat) {
|
function toLocalFileType(stat) {
|
||||||
@@ -3736,6 +3707,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
|||||||
|
|
||||||
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||||
try {
|
try {
|
||||||
|
// The editor comes from the saved setting, never from the caller.
|
||||||
|
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
|
||||||
|
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Choose an external editor to open this file type",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const result = createManagedTempFile(
|
const result = createManagedTempFile(
|
||||||
fileData?.fileName,
|
fileData?.fileName,
|
||||||
fileData?.content,
|
fileData?.content,
|
||||||
@@ -3757,10 +3737,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
|||||||
lastMtimeMs: stat.mtimeMs,
|
lastMtimeMs: stat.mtimeMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorPath =
|
|
||||||
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
|
||||||
? fileData.editorPath.trim()
|
|
||||||
: null;
|
|
||||||
const openError = await openPathWithEditor(result.path, editorPath);
|
const openError = await openPathWithEditor(result.path, editorPath);
|
||||||
if (openError) {
|
if (openError) {
|
||||||
closeExternalEditorSession(editId);
|
closeExternalEditorSession(editId);
|
||||||
@@ -3864,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 {
|
||||||
|
|||||||
+9
-10
@@ -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) => {
|
||||||
@@ -87,16 +96,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||||
getSessionCookie: (name, targetUrl) =>
|
|
||||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
|
||||||
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
|
|
||||||
ipcRenderer.invoke(
|
|
||||||
"wait-session-cookie",
|
|
||||||
name,
|
|
||||||
targetUrl,
|
|
||||||
previousValue,
|
|
||||||
timeoutMs,
|
|
||||||
),
|
|
||||||
|
|
||||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||||
|
|||||||
+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
+1
-1
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.6",
|
"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",
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||||
|
|
||||||
|
## 1.0.7
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A migration file, or a part of one, holding only comments made core fail to start the plugin
|
||||||
|
|
||||||
## 1.0.6
|
## 1.0.6
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.6 | 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
|
||||||
@@ -283,7 +292,8 @@ router.post("/notes", async (req, res) => {
|
|||||||
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||||
`dist/openapi.json` and the docs site renders them.
|
`dist/openapi.json` and the docs site renders them.
|
||||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
||||||
have no actor: check their token yourself and rate limit them.
|
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
|
||||||
|
`publicRateLimit` raises that for a proxied page that loads its own assets.
|
||||||
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
|
||||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||||
`conn.isDataUnlocked()` per message.
|
`conn.isDataUnlocked()` per message.
|
||||||
@@ -333,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
|
||||||
|
|
||||||
@@ -464,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
|
||||||
@@ -518,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
|
||||||
@@ -682,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
|
||||||
|
|
||||||
@@ -720,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.6",
|
"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": {
|
||||||
|
|||||||
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
|
|||||||
* (multer, a raw body for signature checking, a proxied stream).
|
* (multer, a raw body for signature checking, a proxied stream).
|
||||||
*/
|
*/
|
||||||
rawBody?: boolean;
|
rawBody?: boolean;
|
||||||
|
/**
|
||||||
|
* Requests per minute one address may make to this plugin's public routes.
|
||||||
|
* Defaults to 120, which a proxied page loading its own scripts and images
|
||||||
|
* can pass on its own. Capped at 10000, and an admin's
|
||||||
|
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
|
||||||
|
*/
|
||||||
|
publicRateLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1457,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
|
||||||
@@ -1518,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 {
|
||||||
@@ -2006,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. */
|
||||||
@@ -2032,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>;
|
||||||
|
|
||||||
|
|||||||
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (char === ";") {
|
if (char === ";") {
|
||||||
if (current.trim()) statements.push(current.trim());
|
if (hasSql(current)) statements.push(current.trim());
|
||||||
current = "";
|
current = "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.trim()) statements.push(current.trim());
|
if (hasSql(current)) statements.push(current.trim());
|
||||||
return statements;
|
return statements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** False for a chunk that is only comments, which a driver refuses to run. */
|
||||||
|
function hasSql(chunk: string): boolean {
|
||||||
|
return (
|
||||||
|
chunk
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.trim() !== ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** DROP statement for a definition, for removePluginData. */
|
/** DROP statement for a definition, for removePluginData. */
|
||||||
export function dropTableSql(
|
export function dropTableSql(
|
||||||
dialect: SqlDialect,
|
dialect: SqlDialect,
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
||||||
|
isLaunchableFileName: (fileName: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron external editor", () => {
|
||||||
|
it("refuses remote file names the OS would run instead of open", () => {
|
||||||
|
for (const name of [
|
||||||
|
"payload.exe",
|
||||||
|
"run.BAT",
|
||||||
|
"script.ps1",
|
||||||
|
"a.exe. ",
|
||||||
|
"a.cmd...",
|
||||||
|
"../../evil.vbs",
|
||||||
|
"start.command",
|
||||||
|
"launch.desktop",
|
||||||
|
"install.sh",
|
||||||
|
]) {
|
||||||
|
expect(isLaunchableFileName(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens ordinary text files", () => {
|
||||||
|
for (const name of [
|
||||||
|
"nginx.conf",
|
||||||
|
"README.md",
|
||||||
|
"Dockerfile",
|
||||||
|
".bashrc",
|
||||||
|
"config.json",
|
||||||
|
"index.ts",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
]) {
|
||||||
|
expect(isLaunchableFileName(name)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isExternalLoginUrl, isCallbackPort } =
|
||||||
|
require("../electron/external-login.cjs") as {
|
||||||
|
isExternalLoginUrl: (value: unknown) => boolean;
|
||||||
|
isCallbackPort: (value: unknown) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Electron external login", () => {
|
||||||
|
it.each([
|
||||||
|
"https://idp.example.com/authorize?client_id=termix",
|
||||||
|
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
|
||||||
|
])("opens web login URLs: %s", (url) => {
|
||||||
|
expect(isExternalLoginUrl(url)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"file:///C:/Windows/System32/calc.exe",
|
||||||
|
"smb://attacker/share",
|
||||||
|
"ms-msdt:/id PCWDiagnostic",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"not a url",
|
||||||
|
undefined,
|
||||||
|
])("refuses other schemes: %s", (url) => {
|
||||||
|
expect(isExternalLoginUrl(url)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the ports the login screen picks", () => {
|
||||||
|
expect(isCallbackPort(17832)).toBe(true);
|
||||||
|
expect(isCallbackPort(17931)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
|
||||||
|
"refuses callback port %s",
|
||||||
|
(port) => {
|
||||||
|
expect(isCallbackPort(port)).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never hands the session cookie to the renderer over IPC", () => {
|
||||||
|
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
|
||||||
|
expect(main).not.toContain(`"${channel}"`);
|
||||||
|
expect(preload).not.toContain(`"${channel}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launches only the editor saved from a file dialog", () => {
|
||||||
|
expect(main).not.toContain("fileData?.editorPath");
|
||||||
|
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
||||||
|
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||||
expect(main).toContain('"sync-proxy-config"');
|
expect(main).toContain('"sync-proxy-config"');
|
||||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||||
expect(main).toContain("linkedServer.answerLogin(");
|
expect(main).toContain("linkedServer.answerLogin(");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects setting keys that coerce to the protected editor key", async () => {
|
||||||
|
const { runInNewContext } = await import("node:vm");
|
||||||
|
const { vi } = await import("vitest");
|
||||||
|
const writeFileSync = vi.fn();
|
||||||
|
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
|
||||||
|
runInNewContext(
|
||||||
|
main.slice(
|
||||||
|
main.indexOf('ipcMain.handle("set-setting"'),
|
||||||
|
main.indexOf('ipcMain.handle("clear-session-cookies"'),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ipcMain: {
|
||||||
|
handle: (_: string, callback: typeof handler) => {
|
||||||
|
handler = callback;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
|
||||||
|
dialogChosenPaths: new Set(["/chosen/editor"]),
|
||||||
|
app: { getPath: () => "/settings" },
|
||||||
|
path: { join: (...parts: string[]) => parts.join("/") },
|
||||||
|
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
|
||||||
|
console,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
|
||||||
|
expect(handler!({}, key, "/unapproved/editor")).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid setting key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(writeFileSync).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
|
||||||
|
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
|
||||||
|
expect(
|
||||||
|
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
|
||||||
|
).toEqual({ success: true });
|
||||||
|
expect(writeFileSync).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
+48
-34
@@ -3,43 +3,45 @@ const path = require("node:path");
|
|||||||
|
|
||||||
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
|
||||||
|
|
||||||
if (!fs.existsSync(nodePtyDir)) {
|
|
||||||
console.log("[patch-node-pty] node-pty not found, skipping");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
|
||||||
|
|
||||||
const originalHelperRewrite = [
|
// Older installs may still carry the first version of this patch.
|
||||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
const knownHelperRewrites = [
|
||||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
[
|
||||||
].join("\n");
|
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
].join("\n"),
|
||||||
|
[
|
||||||
|
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||||
|
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||||
|
"}",
|
||||||
|
].join("\n"),
|
||||||
|
];
|
||||||
|
|
||||||
const patchedHelperRewrite = [
|
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
|
||||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
const patchedHelperRewrite =
|
||||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
|
||||||
"}",
|
|
||||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
|
||||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
|
||||||
"}",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
function patchUnixTerminalHelperRewrite() {
|
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
|
||||||
if (!fs.existsSync(unixTerminalPath)) {
|
if (!fs.existsSync(file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = fs.readFileSync(unixTerminalPath, "utf8");
|
const source = fs.readFileSync(file, "utf8");
|
||||||
if (source.includes(patchedHelperRewrite)) {
|
if (source.includes(patchedHelperRewrite)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!source.includes(originalHelperRewrite)) {
|
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
|
||||||
|
if (!known) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
unixTerminalPath,
|
file,
|
||||||
source.replace(originalHelperRewrite, patchedHelperRewrite),
|
source.replace(known, () => patchedHelperRewrite),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
|
|||||||
return fixed;
|
return fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
// Tests and afterPack require this file; only a direct run patches node_modules.
|
||||||
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
function main() {
|
||||||
|
if (!fs.existsSync(nodePtyDir)) {
|
||||||
|
console.log("[patch-node-pty] node-pty not found, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (helperRewritePatched) {
|
const helperRewritePatched = patchUnixTerminalHelperRewrite();
|
||||||
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
|
||||||
|
|
||||||
|
if (helperRewritePatched) {
|
||||||
|
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
|
||||||
|
}
|
||||||
|
if (helpersChmodded > 0) {
|
||||||
|
console.log(
|
||||||
|
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!helperRewritePatched && helpersChmodded === 0) {
|
||||||
|
console.log("[patch-node-pty] Already patched or target files not found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (helpersChmodded > 0) {
|
|
||||||
console.log(
|
if (require.main === module) {
|
||||||
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
|
main();
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!helperRewritePatched && helpersChmodded === 0) {
|
|
||||||
console.log("[patch-node-pty] Already patched or target files not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
|
||||||
@@ -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",
|
||||||
|
|||||||
+219
-223
@@ -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";
|
||||||
@@ -79,7 +80,6 @@ import {
|
|||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "./repositories/factory.js";
|
} from "./repositories/factory.js";
|
||||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
|
||||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||||
import {
|
import {
|
||||||
selectExportableSettings,
|
selectExportableSettings,
|
||||||
@@ -1235,227 +1235,221 @@ app.post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
// The file's credential ids belong to the server that wrote it.
|
||||||
try {
|
const credentialIds = new Map<number, number>();
|
||||||
const importedHosts = importDb
|
try {
|
||||||
.prepare("SELECT * FROM ssh_data")
|
const importedCreds = importDb
|
||||||
.all();
|
.prepare("SELECT * FROM ssh_credentials")
|
||||||
const importedHostIds: number[] = [];
|
.all();
|
||||||
for (const host of importedHosts) {
|
for (const cred of importedCreds) {
|
||||||
try {
|
|
||||||
const hostRepository = createCurrentHostRepository();
|
|
||||||
const exists = await hostRepository.existsForImportIdentity(
|
|
||||||
userId,
|
|
||||||
host.ip,
|
|
||||||
host.port,
|
|
||||||
host.username,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
result.summary.skippedItems++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostData = {
|
|
||||||
userId: userId,
|
|
||||||
name: host.name,
|
|
||||||
ip: host.ip,
|
|
||||||
port: host.port,
|
|
||||||
username: host.username,
|
|
||||||
folder: host.folder,
|
|
||||||
tags: host.tags,
|
|
||||||
pin: Boolean(host.pin),
|
|
||||||
authType: host.auth_type,
|
|
||||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
|
||||||
password: host.password,
|
|
||||||
key: host.key,
|
|
||||||
keyPassword: host.key_password,
|
|
||||||
keyType: host.key_type,
|
|
||||||
sudoPassword: host.sudo_password,
|
|
||||||
credentialId: host.credential_id || null,
|
|
||||||
overrideCredentialUsername: Boolean(
|
|
||||||
host.override_credential_username,
|
|
||||||
),
|
|
||||||
jumpHosts: host.jump_hosts,
|
|
||||||
...legacyStatusCheck(host),
|
|
||||||
// Exports from before 2.9.0 carry these in terminal_config.
|
|
||||||
sshOptions:
|
|
||||||
host.ssh_options ??
|
|
||||||
sshOptionsForWrite({
|
|
||||||
terminalConfig: host.terminal_config,
|
|
||||||
}) ??
|
|
||||||
null,
|
|
||||||
notes: host.notes,
|
|
||||||
useSocks5: Boolean(host.use_socks5),
|
|
||||||
socks5Host: host.socks5_host,
|
|
||||||
socks5Port: host.socks5_port,
|
|
||||||
socks5Username: host.socks5_username,
|
|
||||||
socks5Password: host.socks5_password,
|
|
||||||
socks5ProxyChain: host.socks5_proxy_chain,
|
|
||||||
createdAt: host.created_at || new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(hostData as Record<string, unknown>).defaultOverrides =
|
|
||||||
JSON.stringify(
|
|
||||||
await applyHostDefaultsToWrite({
|
|
||||||
ownerId: userId,
|
|
||||||
hostId: null,
|
|
||||||
columns: hostData as Record<string, unknown>,
|
|
||||||
body: hostData as Record<string, unknown>,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const created = await hostRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
hostData,
|
|
||||||
);
|
|
||||||
await applyPluginHostImportSettings(
|
|
||||||
Number(created.id),
|
|
||||||
importedHostPluginSettings(importDb, host),
|
|
||||||
);
|
|
||||||
importedHostIds.push(Number(created.id));
|
|
||||||
await importHostProtocolLogins(
|
|
||||||
importDb,
|
|
||||||
host,
|
|
||||||
Number(created.id),
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
result.summary.sshHostsImported++;
|
|
||||||
} catch (hostError) {
|
|
||||||
result.summary.errors.push(
|
|
||||||
`SSH host import error: ${hostError.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
|
||||||
} catch {
|
|
||||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importedCreds = importDb
|
|
||||||
.prepare("SELECT * FROM ssh_credentials")
|
|
||||||
.all();
|
|
||||||
for (const cred of importedCreds) {
|
|
||||||
try {
|
|
||||||
const credentialRepository =
|
|
||||||
createCurrentCredentialRepository();
|
|
||||||
const exists =
|
|
||||||
await credentialRepository.existsForImportIdentity(
|
|
||||||
userId,
|
|
||||||
cred.name,
|
|
||||||
cred.username,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (exists) {
|
|
||||||
result.summary.skippedItems++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const credData = {
|
|
||||||
userId: userId,
|
|
||||||
name: cred.name,
|
|
||||||
description: cred.description,
|
|
||||||
folder: cred.folder,
|
|
||||||
tags: cred.tags,
|
|
||||||
authType: cred.auth_type,
|
|
||||||
username: cred.username,
|
|
||||||
password: cred.password,
|
|
||||||
key: cred.key,
|
|
||||||
privateKey: cred.private_key,
|
|
||||||
publicKey: cred.public_key,
|
|
||||||
keyPassword: cred.key_password,
|
|
||||||
keyType: cred.key_type,
|
|
||||||
detectedKeyType: cred.detected_key_type,
|
|
||||||
usageCount: cred.usage_count || 0,
|
|
||||||
lastUsed: cred.last_used,
|
|
||||||
createdAt: cred.created_at || new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await credentialRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
credData,
|
|
||||||
);
|
|
||||||
result.summary.sshCredentialsImported++;
|
|
||||||
} catch (credError) {
|
|
||||||
result.summary.errors.push(
|
|
||||||
`SSH credential import error: ${credError.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
apiLogger.info(
|
|
||||||
"ssh_credentials table not found in import file, skipping",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
|
||||||
result.summary.pluginItemsImported += pluginRows.imported;
|
|
||||||
result.summary.skippedItems += pluginRows.skipped;
|
|
||||||
result.summary.errors.push(...pluginRows.errors);
|
|
||||||
|
|
||||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
|
||||||
result.summary.skippedItems += hostDefaults.skipped;
|
|
||||||
if (hostDefaults.imported > 0) {
|
|
||||||
await recompute({ userIds: [userId] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetUser = await userRepository.findById(userId);
|
|
||||||
if (targetUser?.isAdmin) {
|
|
||||||
try {
|
try {
|
||||||
const importedSettings = readImportedSettings(importDb);
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
for (const setting of importedSettings) {
|
const existingId =
|
||||||
try {
|
await credentialRepository.findIdForImportIdentity(
|
||||||
await upsertImportedSetting(setting);
|
userId,
|
||||||
result.summary.settingsImported++;
|
cred.name,
|
||||||
} catch (settingError) {
|
cred.username,
|
||||||
result.summary.errors.push(
|
);
|
||||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
|
||||||
);
|
if (existingId !== null) {
|
||||||
}
|
credentialIds.set(cred.id, existingId);
|
||||||
|
result.summary.skippedItems++;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
apiLogger.info(
|
const credData = {
|
||||||
"settings table not found in import file, skipping",
|
userId: userId,
|
||||||
|
name: cred.name,
|
||||||
|
description: cred.description,
|
||||||
|
folder: cred.folder,
|
||||||
|
tags: cred.tags,
|
||||||
|
authType: cred.auth_type,
|
||||||
|
username: cred.username,
|
||||||
|
password: cred.password,
|
||||||
|
key: cred.key,
|
||||||
|
privateKey: cred.private_key,
|
||||||
|
publicKey: cred.public_key,
|
||||||
|
keyPassword: cred.key_password,
|
||||||
|
keyType: cred.key_type,
|
||||||
|
detectedKeyType: cred.detected_key_type,
|
||||||
|
usageCount: cred.usage_count || 0,
|
||||||
|
lastUsed: cred.last_used,
|
||||||
|
createdAt: cred.created_at || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await credentialRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
credData,
|
||||||
|
);
|
||||||
|
credentialIds.set(cred.id, Number(created.id));
|
||||||
|
result.summary.sshCredentialsImported++;
|
||||||
|
} catch (credError) {
|
||||||
|
result.summary.errors.push(
|
||||||
|
`SSH credential import error: ${credError.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
apiLogger.info(
|
|
||||||
"Settings import skipped - only admin users can import settings",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
apiLogger.info(
|
||||||
|
"ssh_credentials table not found in import file, skipping",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result.success = true;
|
try {
|
||||||
|
const importedHosts = importDb
|
||||||
try {
|
.prepare("SELECT * FROM ssh_data")
|
||||||
await DatabaseSaveTrigger.forceSave("database_import");
|
.all();
|
||||||
} catch (saveError) {
|
const importedHostIds: number[] = [];
|
||||||
apiLogger.error(
|
for (const host of importedHosts) {
|
||||||
"Failed to persist imported data to disk",
|
try {
|
||||||
saveError,
|
const hostRepository = createCurrentHostRepository();
|
||||||
{
|
const exists = await hostRepository.existsForImportIdentity(
|
||||||
operation: "import_force_save_failed",
|
|
||||||
userId,
|
userId,
|
||||||
},
|
host.ip,
|
||||||
);
|
host.port,
|
||||||
|
host.username,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
result.summary.skippedItems++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostData = {
|
||||||
|
userId: userId,
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
folder: host.folder,
|
||||||
|
tags: host.tags,
|
||||||
|
pin: Boolean(host.pin),
|
||||||
|
authType: host.auth_type,
|
||||||
|
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||||
|
password: host.password,
|
||||||
|
key: host.key,
|
||||||
|
keyPassword: host.key_password,
|
||||||
|
keyType: host.key_type,
|
||||||
|
sudoPassword: host.sudo_password,
|
||||||
|
credentialId: credentialIds.get(host.credential_id) ?? null,
|
||||||
|
overrideCredentialUsername: Boolean(
|
||||||
|
host.override_credential_username,
|
||||||
|
),
|
||||||
|
jumpHosts: host.jump_hosts,
|
||||||
|
...legacyStatusCheck(host),
|
||||||
|
// Exports from before 2.9.0 carry these in terminal_config.
|
||||||
|
sshOptions:
|
||||||
|
host.ssh_options ??
|
||||||
|
sshOptionsForWrite({
|
||||||
|
terminalConfig: host.terminal_config,
|
||||||
|
}) ??
|
||||||
|
null,
|
||||||
|
notes: host.notes,
|
||||||
|
useSocks5: Boolean(host.use_socks5),
|
||||||
|
socks5Host: host.socks5_host,
|
||||||
|
socks5Port: host.socks5_port,
|
||||||
|
socks5Username: host.socks5_username,
|
||||||
|
socks5Password: host.socks5_password,
|
||||||
|
socks5ProxyChain: host.socks5_proxy_chain,
|
||||||
|
createdAt: host.created_at || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(hostData as Record<string, unknown>).defaultOverrides =
|
||||||
|
JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId: null,
|
||||||
|
columns: hostData as Record<string, unknown>,
|
||||||
|
body: hostData as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const created = await hostRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
hostData,
|
||||||
|
);
|
||||||
|
await applyPluginHostImportSettings(
|
||||||
|
Number(created.id),
|
||||||
|
importedHostPluginSettings(importDb, host),
|
||||||
|
);
|
||||||
|
importedHostIds.push(Number(created.id));
|
||||||
|
await importHostProtocolLogins(
|
||||||
|
importDb,
|
||||||
|
host,
|
||||||
|
Number(created.id),
|
||||||
|
userId,
|
||||||
|
credentialIds,
|
||||||
|
);
|
||||||
|
result.summary.sshHostsImported++;
|
||||||
|
} catch (hostError) {
|
||||||
|
result.summary.errors.push(
|
||||||
|
`SSH host import error: ${hostError.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||||
|
for (const hostId of importedHostIds) {
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||||
|
result.summary.pluginItemsImported += pluginRows.imported;
|
||||||
|
result.summary.skippedItems += pluginRows.skipped;
|
||||||
|
result.summary.errors.push(...pluginRows.errors);
|
||||||
|
|
||||||
|
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||||
|
result.summary.skippedItems += hostDefaults.skipped;
|
||||||
|
if (hostDefaults.imported > 0) {
|
||||||
|
await recompute({ userIds: [userId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await userRepository.findById(userId);
|
||||||
|
if (targetUser?.isAdmin) {
|
||||||
|
try {
|
||||||
|
const importedSettings = readImportedSettings(importDb);
|
||||||
|
for (const setting of importedSettings) {
|
||||||
|
try {
|
||||||
|
await upsertImportedSetting(setting);
|
||||||
|
result.summary.settingsImported++;
|
||||||
|
} catch (settingError) {
|
||||||
|
result.summary.errors.push(
|
||||||
|
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
apiLogger.info("settings table not found in import file, skipping");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
apiLogger.info(
|
||||||
|
"Settings import skipped - only admin users can import settings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await DatabaseSaveTrigger.forceSave("database_import");
|
||||||
|
} catch (saveError) {
|
||||||
|
apiLogger.error(
|
||||||
|
"Failed to persist imported data to disk",
|
||||||
|
saveError,
|
||||||
|
{
|
||||||
|
operation: "import_force_save_failed",
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (importDb) {
|
if (importDb) {
|
||||||
importDb.close();
|
importDb.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(req.file.path);
|
|
||||||
} catch {
|
|
||||||
apiLogger.warn("Failed to clean up uploaded file", {
|
|
||||||
operation: "file_cleanup_warning",
|
|
||||||
filePath: req.file.path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
message: result.success
|
message: result.success
|
||||||
@@ -1472,17 +1466,6 @@ app.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (req.file?.path && fs.existsSync(req.file.path)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(req.file.path);
|
|
||||||
} catch {
|
|
||||||
apiLogger.warn("Failed to clean up uploaded file after error", {
|
|
||||||
operation: "file_cleanup_error",
|
|
||||||
filePath: req.file.path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiLogger.error("SQLite import failed", error, {
|
apiLogger.error("SQLite import failed", error, {
|
||||||
operation: "sqlite_import_api_failed",
|
operation: "sqlite_import_api_failed",
|
||||||
userId: (req as AuthenticatedRequest).userId,
|
userId: (req as AuthenticatedRequest).userId,
|
||||||
@@ -1491,6 +1474,17 @@ app.post(
|
|||||||
error: "Failed to import SQLite data",
|
error: "Failed to import SQLite data",
|
||||||
details: getErrorMessage(error),
|
details: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
if (req.file) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(req.file.path, { force: true });
|
||||||
|
} catch {
|
||||||
|
apiLogger.warn("Failed to clean up uploaded file", {
|
||||||
|
operation: "file_cleanup_warning",
|
||||||
|
filePath: req.file.path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1726,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 {
|
||||||
@@ -2012,14 +2007,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A host's plugin protocol logins from an export. The file's credential ids
|
* A host's plugin protocol logins from an export. The file's credential ids
|
||||||
* belong to the server that wrote it, so a login keeps one only when it is
|
* belong to the server that wrote it, so a login keeps one only when it maps
|
||||||
* a credential the importing user can use.
|
* to a credential imported alongside it.
|
||||||
*/
|
*/
|
||||||
async function importHostProtocolLogins(
|
async function importHostProtocolLogins(
|
||||||
importDb: Database.Database,
|
importDb: Database.Database,
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
credentialIds: Map<number, number>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let rows: Array<Record<string, unknown>>;
|
let rows: Array<Record<string, unknown>>;
|
||||||
try {
|
try {
|
||||||
@@ -2040,7 +2036,7 @@ async function importHostProtocolLogins(
|
|||||||
}
|
}
|
||||||
protocolAuth[String(row.protocol)] = {
|
protocolAuth[String(row.protocol)] = {
|
||||||
authType: row.auth_type,
|
authType: row.auth_type,
|
||||||
credentialId: row.credential_id,
|
credentialId: credentialIds.get(row.credential_id as number) ?? null,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
password: row.password,
|
password: row.password,
|
||||||
fields,
|
fields,
|
||||||
|
|||||||
@@ -925,6 +925,38 @@ const dropRetiredColumns = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Registration checked for a taken username and then inserted with nothing
|
||||||
|
// enforcing it, so concurrent sign-ups could create two accounts with one
|
||||||
|
// name, and every lookup by username picked between them arbitrarily. Later
|
||||||
|
// duplicates are renamed rather than deleted, so no account is lost.
|
||||||
|
const enforceUniqueUsernames = () => {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
WITH ranked AS MATERIALIZED (
|
||||||
|
SELECT id, username,
|
||||||
|
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||||
|
FROM users
|
||||||
|
), candidates AS (
|
||||||
|
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||||
|
), available AS (
|
||||||
|
SELECT MIN(candidate) AS candidate FROM candidates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||||
|
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to enforce unique usernames", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
repairSnippetsColumns(sqlite);
|
repairSnippetsColumns(sqlite);
|
||||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||||
@@ -996,6 +1028,7 @@ const migrateSchema = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
enforceUniqueUsernames();
|
||||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||||
@@ -1893,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dropMysqlForeignKeys(
|
||||||
|
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||||
|
table: string,
|
||||||
|
column: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const keys = resultRows<{ name: string }>(
|
||||||
|
await remote.execute(
|
||||||
|
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const key of keys) {
|
||||||
|
await remote.execute(
|
||||||
|
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||||
* longer knows them. Dropped by hand, only where they still exist.
|
* longer knows them. Dropped by hand, only where they still exist.
|
||||||
@@ -1928,6 +1978,10 @@ async function dropRemoteRetired(
|
|||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
if (!existing.has(`${table}.${column}`)) continue;
|
if (!existing.has(`${table}.${column}`)) continue;
|
||||||
try {
|
try {
|
||||||
|
// Postgres drops a column's foreign keys with it; MySQL refuses.
|
||||||
|
if (dialect === "mysql") {
|
||||||
|
await dropMysqlForeignKeys(remote, table, column);
|
||||||
|
}
|
||||||
await remote.execute(
|
await remote.execute(
|
||||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
*
|
*
|
||||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
* domain), so a user who never signed in to 2.9 still gets them.
|
||||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
*
|
||||||
|
* ssh_data.vault_profile_id goes because its foreign key follows the vault
|
||||||
|
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
|
||||||
|
* refuses every write to ssh_data and the other engines refuse the uninstall.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||||
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
|||||||
"telnet_port",
|
"telnet_port",
|
||||||
"tunnel_connections",
|
"tunnel_connections",
|
||||||
"use_warpgate",
|
"use_warpgate",
|
||||||
|
"vault_profile_id",
|
||||||
"vnc_port",
|
"vnc_port",
|
||||||
"web_ui_config",
|
"web_ui_config",
|
||||||
"wol_broadcast_address",
|
"wol_broadcast_address",
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ import {
|
|||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = mysqlTable("users", {
|
export const users = mysqlTable(
|
||||||
id: varchar("id", { length: 255 }).primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
username: varchar("username", { length: 255 }).notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = mysqlTable("settings", {
|
export const settings = mysqlTable("settings", {
|
||||||
key: varchar("key", { length: 255 }).primaryKey(),
|
key: varchar("key", { length: 255 }).primaryKey(),
|
||||||
|
|||||||
@@ -21,13 +21,19 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable(
|
||||||
id: varchar("id", { length: 255 }).primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
isAdmin: boolean("is_admin").notNull().default(false),
|
username: varchar("username", { length: 255 }).notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: boolean("is_admin").notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = pgTable("settings", {
|
export const settings = pgTable("settings", {
|
||||||
key: varchar("key", { length: 255 }).primaryKey(),
|
key: varchar("key", { length: 255 }).primaryKey(),
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ import {
|
|||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable(
|
||||||
id: text("id").primaryKey(),
|
"users",
|
||||||
username: text("username").notNull(),
|
{
|
||||||
passwordHash: text("password_hash").notNull(),
|
id: text("id").primaryKey(),
|
||||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
username: text("username").notNull(),
|
||||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
passwordHash: text("password_hash").notNull(),
|
||||||
});
|
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||||
|
registeredAt: text("registered_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||||
|
);
|
||||||
|
|
||||||
export const settings = sqliteTable("settings", {
|
export const settings = sqliteTable("settings", {
|
||||||
key: text("key").primaryKey(),
|
key: text("key").primaryKey(),
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ export class CredentialRepository {
|
|||||||
.orderBy(desc(sshCredentials.updatedAt));
|
.orderBy(desc(sshCredentials.updatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
async existsForImportIdentity(
|
async findIdForImportIdentity(
|
||||||
userId: string,
|
userId: string,
|
||||||
name: string,
|
name: string,
|
||||||
username: string | null,
|
username: string | null,
|
||||||
): Promise<boolean> {
|
): Promise<number | null> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.select({ id: sshCredentials.id })
|
.select({ id: sshCredentials.id })
|
||||||
.from(sshCredentials)
|
.from(sshCredentials)
|
||||||
@@ -120,7 +120,7 @@ export class CredentialRepository {
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return rows.length > 0;
|
return rows[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findDecryptedByIdForUser(
|
async findDecryptedByIdForUser(
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
|
||||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
|
||||||
|
|
||||||
export interface SqliteForeignKeyClient {
|
|
||||||
exec(sql: string): unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withSqliteForeignKeysDisabled<T>(
|
|
||||||
sqlite: SqliteForeignKeyClient,
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} finally {
|
|
||||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs a bulk import with foreign keys relaxed.
|
|
||||||
*
|
|
||||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
|
||||||
* constraints have to stand down for the duration.
|
|
||||||
*
|
|
||||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
|
||||||
* writes through repositories and handles individual row failures, so it must
|
|
||||||
* still be allowed to run there; only SQLite needs this connection-local
|
|
||||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
|
||||||
*/
|
|
||||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const dialect = resolveDatabaseDialect();
|
|
||||||
if (!needsExplicitPersist(dialect)) {
|
|
||||||
return operation();
|
|
||||||
}
|
|
||||||
|
|
||||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,27 @@ const userColumns = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
|
||||||
|
const UNIQUE_VIOLATION_CODES = new Set([
|
||||||
|
"SQLITE_CONSTRAINT_UNIQUE",
|
||||||
|
"23505",
|
||||||
|
"ER_DUP_ENTRY",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether creating a user failed because the username is taken. The id is
|
||||||
|
* generated, so the username index is the only one a new row can collide on.
|
||||||
|
* drizzle wraps driver errors, so the code may sit on a cause.
|
||||||
|
*/
|
||||||
|
export function isUsernameTaken(error: unknown): boolean {
|
||||||
|
for (let e = error; e instanceof Error; e = e.cause) {
|
||||||
|
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: DatabaseContext,
|
private readonly context: DatabaseContext,
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
@@ -588,6 +595,7 @@ export function registerHostBulkRoutes(
|
|||||||
|
|
||||||
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";
|
||||||
@@ -797,6 +805,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,7 +837,7 @@ 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.failed} failed`,
|
||||||
@@ -948,6 +957,7 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>,
|
||||||
@@ -1023,6 +1033,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,7 +1045,7 @@ 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.failed} failed`,
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -243,7 +243,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* frontends in the same order the server does.
|
* frontends in the same order the server does.
|
||||||
*
|
*
|
||||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||||
* only run on that server and stay off here on purpose.
|
* only run on that server and stay off here on purpose, and, for
|
||||||
|
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||||
|
* decides.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* responses:
|
* responses:
|
||||||
@@ -370,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
features,
|
features,
|
||||||
env,
|
env,
|
||||||
signedBy: loaded?.signedBy ?? null,
|
signedBy: loaded?.signedBy ?? null,
|
||||||
|
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
|
||||||
capabilities,
|
capabilities,
|
||||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||||
@@ -393,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
if (!(await isLinkedDesktop())) return false;
|
||||||
const { getLink } = await import("../../sync/client/link-store.js");
|
|
||||||
if (!(await getLink())) return false;
|
|
||||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||||
return isServerManaged(pluginId);
|
return isServerManaged(pluginId);
|
||||||
}
|
}
|
||||||
@@ -500,7 +501,7 @@ router.get(
|
|||||||
* /plugins/onboarding/apply:
|
* /plugins/onboarding/apply:
|
||||||
* post:
|
* post:
|
||||||
* summary: Apply the onboarding plugin picker
|
* summary: Apply the onboarding plugin picker
|
||||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* requestBody:
|
* requestBody:
|
||||||
@@ -887,7 +888,7 @@ router.delete(
|
|||||||
* /plugins/{id}/state:
|
* /plugins/{id}/state:
|
||||||
* patch:
|
* patch:
|
||||||
* summary: Enable or disable a plugin
|
* summary: Enable or disable a plugin
|
||||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -909,6 +910,12 @@ router.delete(
|
|||||||
* properties:
|
* properties:
|
||||||
* enabled:
|
* enabled:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* capabilities:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: The plugin's new state.
|
* description: The plugin's new state.
|
||||||
@@ -962,7 +969,26 @@ router.patch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await setPluginState(pluginId, enabled);
|
let result;
|
||||||
|
if (enabled && (await isLinkedDesktop())) {
|
||||||
|
const version = readVersion(req.body);
|
||||||
|
const capabilities = readCapabilities(req.body);
|
||||||
|
if (version === null || capabilities === null) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid plugin version or capabilities" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { enablePluginWithConsent } =
|
||||||
|
await import("../../plugins/manage.js");
|
||||||
|
result = await enablePluginWithConsent(pluginId, {
|
||||||
|
version,
|
||||||
|
capabilities,
|
||||||
|
userId: (req as AuthenticatedRequest).userId as string,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await setPluginState(pluginId, enabled);
|
||||||
|
}
|
||||||
await auditPluginAction(
|
await auditPluginAction(
|
||||||
req,
|
req,
|
||||||
enabled ? "enable_plugin" : "disable_plugin",
|
enabled ? "enable_plugin" : "disable_plugin",
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
listUserSecondFactors,
|
listUserSecondFactors,
|
||||||
resetUserSecondFactors,
|
resetUserSecondFactors,
|
||||||
} from "../../auth/second-factor-admin.js";
|
} from "../../auth/second-factor-admin.js";
|
||||||
import type {
|
import {
|
||||||
UserRecord,
|
isUsernameTaken,
|
||||||
UserRepository,
|
type UserRecord,
|
||||||
|
type UserRepository,
|
||||||
} from "../repositories/user-repository.js";
|
} from "../repositories/user-repository.js";
|
||||||
|
|
||||||
function isNonEmptyString(val: unknown): val is string {
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
|
|||||||
toast: { type: "success", message: `User created: ${username}` },
|
toast: { type: "success", message: `User created: ${username}` },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isUsernameTaken(err)) {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
authLogger.error("Failed to admin-create user", err);
|
authLogger.error("Failed to admin-create user", err);
|
||||||
res.status(500).json({ error: "Failed to create user" });
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ import {
|
|||||||
createCurrentUserAuthRepository,
|
createCurrentUserAuthRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { UserRecord } from "../repositories/user-repository.js";
|
import {
|
||||||
|
isUsernameTaken,
|
||||||
|
type UserRecord,
|
||||||
|
} from "../repositories/user-repository.js";
|
||||||
import {
|
import {
|
||||||
getTrustedProxyAuthConfig,
|
getTrustedProxyAuthConfig,
|
||||||
|
getTrustedProxySourceAddress,
|
||||||
isTrustedProxyAddress,
|
isTrustedProxyAddress,
|
||||||
isTrustedProxyAuthEnabled,
|
isTrustedProxyAuthEnabled,
|
||||||
resolveTrustedProxyRoles,
|
resolveTrustedProxyRoles,
|
||||||
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
|
|||||||
toast: { type: "success", message: `User created: ${username}` },
|
toast: { type: "success", message: `User created: ${username}` },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Lost a race with a concurrent sign-up for the same name.
|
||||||
|
if (isUsernameTaken(err)) {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
authLogger.error("Failed to create user", err);
|
authLogger.error("Failed to create user", err);
|
||||||
res.status(500).json({ error: "Failed to create user" });
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
}
|
}
|
||||||
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!config.enabled) return res.json({ enabled: false });
|
if (!config.enabled) return res.json({ enabled: false });
|
||||||
|
|
||||||
const sourceAddress = req.socket.remoteAddress;
|
const sourceAddress = getTrustedProxySourceAddress(req);
|
||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
|
|||||||
@@ -263,28 +263,6 @@ export class SSHHostKeyVerifier {
|
|||||||
changeCount: host.hostKeyChangedCount || 0,
|
changeCount: host.hostKeyChangedCount || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isJumpHost) {
|
|
||||||
await this.updateHostKey(
|
|
||||||
hostId,
|
|
||||||
fingerprint,
|
|
||||||
keyType,
|
|
||||||
algorithm,
|
|
||||||
host.hostKeyChangedCount || 0,
|
|
||||||
);
|
|
||||||
sshLogger.warn("Jump host key changed - auto-accepted", {
|
|
||||||
operation: "host_key_updated",
|
|
||||||
hostId,
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
fingerprint,
|
|
||||||
keyType,
|
|
||||||
userId,
|
|
||||||
isJumpHost: true,
|
|
||||||
});
|
|
||||||
verify(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ws) {
|
if (!ws) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
"Host key changed - please connect via Terminal to verify the new key",
|
"Host key changed - please connect via Terminal to verify the new key",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export class SSHConnectionPool {
|
|||||||
private async createPooledClient(
|
private async createPooledClient(
|
||||||
key: string,
|
key: string,
|
||||||
factory: () => Promise<Client>,
|
factory: () => Promise<Client>,
|
||||||
existing: PooledConnection[],
|
|
||||||
): Promise<Client> {
|
): Promise<Client> {
|
||||||
const generation = this.generations.get(key) || 0;
|
const generation = this.generations.get(key) || 0;
|
||||||
this.pendingConnections.set(
|
this.pendingConnections.set(
|
||||||
@@ -109,8 +108,11 @@ export class SSHConnectionPool {
|
|||||||
inUse: true,
|
inUse: true,
|
||||||
hostKey: key,
|
hostKey: key,
|
||||||
};
|
};
|
||||||
existing.push(pooled);
|
// Re-read after the await: concurrent connects and removals may have
|
||||||
this.connections.set(key, existing);
|
// replaced this key's array in the meantime.
|
||||||
|
const connections = this.connections.get(key) ?? [];
|
||||||
|
connections.push(pooled);
|
||||||
|
this.connections.set(key, connections);
|
||||||
|
|
||||||
client.on("end", () => {
|
client.on("end", () => {
|
||||||
this.removeConnection(key, client);
|
this.removeConnection(key, client);
|
||||||
@@ -187,7 +189,7 @@ export class SSHConnectionPool {
|
|||||||
if (queue.length === 0) this.waiters.delete(key);
|
if (queue.length === 0) this.waiters.delete(key);
|
||||||
else this.waiters.set(key, queue);
|
else this.waiters.set(key, queue);
|
||||||
clearTimeout(waiter.timer);
|
clearTimeout(waiter.timer);
|
||||||
this.createPooledClient(key, waiter.factory, connections)
|
this.createPooledClient(key, waiter.factory)
|
||||||
.then(waiter.resolve)
|
.then(waiter.resolve)
|
||||||
.catch(waiter.reject);
|
.catch(waiter.reject);
|
||||||
return;
|
return;
|
||||||
@@ -225,7 +227,7 @@ export class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasCapacity(key, connections)) {
|
if (this.hasCapacity(key, connections)) {
|
||||||
return this.createPooledClient(key, factory, connections);
|
return this.createPooledClient(key, factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.enqueueWaiter(key, factory);
|
return this.enqueueWaiter(key, factory);
|
||||||
|
|||||||
@@ -1,50 +1,59 @@
|
|||||||
|
import path from "node:path";
|
||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
|
import * as tar from "tar";
|
||||||
|
|
||||||
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
|
* Reads every copy of one file out of a .tmxplug (a gzipped tar) without
|
||||||
* disk. Returns null when the archive has no such file.
|
* unpacking it to disk. Uses the same tar parser as the loader's unpack, so
|
||||||
|
* pax and long-name headers resolve the same way, and only plain files count
|
||||||
|
* because they are the only kind the unpack writes. Names compare
|
||||||
|
* case-insensitively since a case-insensitive disk folds them together.
|
||||||
*/
|
*/
|
||||||
export function readArtifactFile(
|
export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] {
|
||||||
buffer: Buffer,
|
const archive = zlib.gunzipSync(buffer, {
|
||||||
wanted: string,
|
maxOutputLength: MAX_UNPACKED_BYTES,
|
||||||
): Buffer | null {
|
});
|
||||||
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
|
const key = wanted.toLowerCase();
|
||||||
let offset = 0;
|
const found: Buffer[] = [];
|
||||||
while (offset + 512 <= tar.length) {
|
const parser = new tar.Parser({
|
||||||
const header = tar.subarray(offset, offset + 512);
|
strict: true,
|
||||||
if (header.every((byte) => byte === 0)) break;
|
onReadEntry: (entry) => {
|
||||||
const field = (start: number, length: number) =>
|
const name = path.posix.normalize(entry.path.replace(/\\/g, "/"));
|
||||||
header
|
if (entry.type !== "File" || name.toLowerCase() !== key) {
|
||||||
.subarray(start, start + length)
|
entry.resume();
|
||||||
.toString("utf8")
|
return;
|
||||||
.replace(/\0.*$/s, "");
|
}
|
||||||
const prefix = field(345, 155);
|
const chunks: Buffer[] = [];
|
||||||
const name = (
|
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
|
entry.on("end", () => found.push(Buffer.concat(chunks)));
|
||||||
).replace(/^\.\//, "");
|
},
|
||||||
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
|
});
|
||||||
if (!Number.isFinite(size) || size < 0) break;
|
// The parser runs synchronously on an in-memory buffer.
|
||||||
const start = offset + 512;
|
parser.end(archive);
|
||||||
if (name === wanted) return tar.subarray(start, start + size);
|
return found;
|
||||||
offset = start + Math.ceil(size / 512) * 512;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
|
/**
|
||||||
|
* The manifest.json packed in a .tmxplug, parsed but not validated. An
|
||||||
|
* archive with more than one is refused: the unpack keeps the last copy, so
|
||||||
|
* whichever one this read the other could carry different capabilities.
|
||||||
|
*/
|
||||||
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
||||||
let raw: Buffer | null;
|
let copies: Buffer[];
|
||||||
try {
|
try {
|
||||||
raw = readArtifactFile(buffer, "manifest.json");
|
copies = readArtifactFiles(buffer, "manifest.json");
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("the file is not a valid .tmxplug archive");
|
throw new Error("the file is not a valid .tmxplug archive");
|
||||||
}
|
}
|
||||||
if (!raw) throw new Error("the archive has no manifest.json");
|
if (copies.length === 0) throw new Error("the archive has no manifest.json");
|
||||||
|
if (copies.length > 1) {
|
||||||
|
throw new Error("the archive has more than one manifest.json");
|
||||||
|
}
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw.toString("utf8"));
|
parsed = JSON.parse(copies[0].toString("utf8"));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("manifest.json is not valid JSON");
|
throw new Error("manifest.json is not valid JSON");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
|
|||||||
* Requests per minute one address may make to one plugin's public routes.
|
* Requests per minute one address may make to one plugin's public routes.
|
||||||
* They run without a login, so they are the cheap way to hammer a plugin.
|
* They run without a login, so they are the cheap way to hammer a plugin.
|
||||||
*/
|
*/
|
||||||
function publicRateLimit(): number {
|
const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
|
||||||
|
|
||||||
|
/** The admin's limit when set, else the plugin's own, else 120. */
|
||||||
|
function publicRateLimit(pluginLimit?: number): number {
|
||||||
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||||
|
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
|
||||||
|
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||||
|
}
|
||||||
|
return 120;
|
||||||
}
|
}
|
||||||
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||||
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
|
|||||||
pluginId: string,
|
pluginId: string,
|
||||||
ip: string,
|
ip: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
pluginLimit?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (publicHits.size > 10_000) {
|
if (publicHits.size > 10_000) {
|
||||||
for (const [hitKey, entry] of publicHits) {
|
for (const [hitKey, entry] of publicHits) {
|
||||||
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.count++;
|
entry.count++;
|
||||||
return entry.count > publicRateLimit();
|
return entry.count > publicRateLimit(pluginLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
@@ -319,7 +327,9 @@ export function createPluginRouter({
|
|||||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (isPublicRequest(req, publicPaths, inner)) {
|
if (isPublicRequest(req, publicPaths, inner)) {
|
||||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
if (overPublicRateLimit(pluginId, ip)) {
|
if (
|
||||||
|
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
|
||||||
|
) {
|
||||||
res.status(429).json({ error: "Too many requests" });
|
res.status(429).json({ error: "Too many requests" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -715,6 +715,28 @@ export async function planStateChange(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A local admin consents to the exact mirrored version before granting it access. */
|
||||||
|
export function enablePluginWithConsent(
|
||||||
|
pluginId: string,
|
||||||
|
options: { version?: string; capabilities?: string[]; userId: string },
|
||||||
|
): Promise<StateChangePlan & { state: string }> {
|
||||||
|
return serialized(async () => {
|
||||||
|
const plugin = getPluginRuntime().loader.get(pluginId);
|
||||||
|
if (!plugin)
|
||||||
|
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||||
|
if (options.version !== plugin.manifest.version) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
"The plugin version changed; review it again",
|
||||||
|
409,
|
||||||
|
"CONSENT_MISMATCH",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertConsent(options.capabilities, plugin.manifest.capabilities);
|
||||||
|
await grantDeclared(plugin, options.userId);
|
||||||
|
return setPluginStateUnlocked(pluginId, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function setPluginState(
|
export function setPluginState(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
type RegistrySource,
|
type RegistrySource,
|
||||||
} from "./registry-index.js";
|
} from "./registry-index.js";
|
||||||
import {
|
import {
|
||||||
|
COMMUNITY_PLUGIN_KEYS,
|
||||||
|
TRUSTED_PLUGIN_KEYS,
|
||||||
|
getCustomPluginKeys,
|
||||||
isPluginPublicKey,
|
isPluginPublicKey,
|
||||||
pluginKeyId,
|
pluginKeyId,
|
||||||
setCustomPluginKeys,
|
setCustomPluginKeys,
|
||||||
@@ -28,6 +31,8 @@ import {
|
|||||||
const CONFIG_KEY = "plugin_registries";
|
const CONFIG_KEY = "plugin_registries";
|
||||||
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||||
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||||
|
/** `addedIn` of a key only the linked server vouches for. */
|
||||||
|
const SYNCED = "synced";
|
||||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const MAX_ID_LENGTH = 40;
|
const MAX_ID_LENGTH = 40;
|
||||||
const MAX_NAME_LENGTH = 80;
|
const MAX_NAME_LENGTH = 80;
|
||||||
@@ -117,8 +122,8 @@ async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
|||||||
await applyCustomKeys(config);
|
await applyCustomKeys(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toKey(publicKey: string): TrustedPluginKey {
|
function toKey(publicKey: string, addedIn = "admin"): TrustedPluginKey {
|
||||||
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
return { id: pluginKeyId(publicKey), publicKey, addedIn };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSyncedKeys(): Promise<string[]> {
|
async function readSyncedKeys(): Promise<string[]> {
|
||||||
@@ -134,19 +139,32 @@ async function readSyncedKeys(): Promise<string[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusts the key of every custom registry, on or off: a plugin installed
|
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||||
* from a registry that was later turned off still has to load.
|
* from a registry that was later turned off still has to load. A key added
|
||||||
|
* here wins over the same key synced from a server.
|
||||||
*/
|
*/
|
||||||
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||||
const keys = new Map<string, TrustedPluginKey>();
|
const keys = new Map<string, TrustedPluginKey>();
|
||||||
|
for (const publicKey of await readSyncedKeys()) {
|
||||||
|
keys.set(pluginKeyId(publicKey), toKey(publicKey, SYNCED));
|
||||||
|
}
|
||||||
for (const entry of config.custom) {
|
for (const entry of config.custom) {
|
||||||
keys.set(entry.keyId, toKey(entry.publicKey));
|
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||||
}
|
}
|
||||||
for (const publicKey of await readSyncedKeys()) {
|
|
||||||
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
|
||||||
}
|
|
||||||
setCustomPluginKeys([...keys.values()]);
|
setCustomPluginKeys([...keys.values()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether this app trusts the key independently of its linked server. */
|
||||||
|
export function isLocallyTrustedPluginKey(keyId: string): boolean {
|
||||||
|
return (
|
||||||
|
[...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS].some(
|
||||||
|
(key) => key.id === keyId,
|
||||||
|
) ||
|
||||||
|
getCustomPluginKeys().some(
|
||||||
|
(key) => key.id === keyId && key.addedIn !== SYNCED,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Called before the boot scan so plugins from custom registries load. */
|
/** Called before the boot scan so plugins from custom registries load. */
|
||||||
export async function loadCustomRegistryKeys(): Promise<void> {
|
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -351,7 +351,10 @@ export class SyncEngine {
|
|||||||
const key = recordKey(entity.type, row.syncId);
|
const key = recordKey(entity.type, row.syncId);
|
||||||
const record = records.get(key);
|
const record = records.get(key);
|
||||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||||
if (record?.errorHash === row.hash) continue;
|
// Unavailable server rows can recover without another local edit.
|
||||||
|
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ops.push({
|
ops.push({
|
||||||
entityType: entity.type,
|
entityType: entity.type,
|
||||||
syncId: row.syncId,
|
syncId: row.syncId,
|
||||||
@@ -367,7 +370,12 @@ export class SyncEngine {
|
|||||||
if (record.revision === 0) continue;
|
if (record.revision === 0) continue;
|
||||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||||
if (record.errorHash === DELETED_HASH) continue;
|
if (
|
||||||
|
record.errorHash === DELETED_HASH &&
|
||||||
|
record.error !== "unavailable"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ops.push({
|
ops.push({
|
||||||
entityType: entity.type,
|
entityType: entity.type,
|
||||||
syncId: record.syncId,
|
syncId: record.syncId,
|
||||||
@@ -433,6 +441,9 @@ export class SyncEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A live row the server could not send says nothing; keep ours to retry.
|
||||||
|
if (!result.deleted && !result.row) return;
|
||||||
|
|
||||||
// Conflict: the server moved on since this device last saw the row. Its
|
// Conflict: the server moved on since this device last saw the row. Its
|
||||||
// version wins here, and a local edit it replaced is kept for the user.
|
// version wins here, and a local edit it replaced is kept for the user.
|
||||||
if (op.op === "upsert" && op.row) {
|
if (op.op === "upsert" && op.row) {
|
||||||
@@ -445,7 +456,7 @@ export class SyncEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const resolvers = createResolvers(userId);
|
const resolvers = createResolvers(userId);
|
||||||
if (result.deleted || !result.row) {
|
if (result.deleted) {
|
||||||
await deleteStoredRow(entity, userId, op.syncId);
|
await deleteStoredRow(entity, userId, op.syncId);
|
||||||
await putRecord(userId, op.entityType, op.syncId, {
|
await putRecord(userId, op.entityType, op.syncId, {
|
||||||
revision: result.revision,
|
revision: result.revision,
|
||||||
@@ -488,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);
|
||||||
@@ -517,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)
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
||||||
* there is installed here, on or off to match, with the capabilities an
|
* there is installed here, on or off to match, with the capabilities an
|
||||||
* admin granted it there. Plugins marked "local" are left to the desktop and
|
* admin granted it there. Plugins marked "local" are left to the desktop and
|
||||||
* "server" ones never run on a linked desktop.
|
* "server" ones never run on a linked desktop. A user plugin no key trusted
|
||||||
|
* here signed is only ever switched off; the desktop's admin turns it on.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -21,7 +22,10 @@ import {
|
|||||||
unloadPlugin,
|
unloadPlugin,
|
||||||
} from "../../plugins/index.js";
|
} from "../../plugins/index.js";
|
||||||
import { getPluginsDir } from "../../plugins/paths.js";
|
import { getPluginsDir } from "../../plugins/paths.js";
|
||||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
import {
|
||||||
|
isLocallyTrustedPluginKey,
|
||||||
|
setSyncedRegistryKeys,
|
||||||
|
} from "../../plugins/registries.js";
|
||||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||||
import {
|
import {
|
||||||
createCurrentPluginPermissionGrantRepository,
|
createCurrentPluginPermissionGrantRepository,
|
||||||
@@ -57,6 +61,14 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
|||||||
return record?.state === "enabled";
|
return record?.state === "enabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed by a key this desktop trusts on its own. A key the server sent
|
||||||
|
* would let the server's admin sign anything, so it does not count.
|
||||||
|
*/
|
||||||
|
function vouchedHere(local: LoadedPlugin): boolean {
|
||||||
|
return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy);
|
||||||
|
}
|
||||||
|
|
||||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
/** Downloads next to the installed copy, which stays until the swap. */
|
/** Downloads next to the installed copy, which stays until the swap. */
|
||||||
@@ -112,6 +124,10 @@ async function replaceWithDownload(
|
|||||||
if (previous) await unloadPlugin(pluginId);
|
if (previous) await unloadPlugin(pluginId);
|
||||||
try {
|
try {
|
||||||
const installed = await installPluginArtifact(file);
|
const installed = await installPluginArtifact(file);
|
||||||
|
// Approval belongs to the reviewed version, regardless of remote metadata.
|
||||||
|
if (wasEnabled && installed.source === "user" && !vouchedHere(installed)) {
|
||||||
|
await setPluginEnabled(pluginId, false);
|
||||||
|
}
|
||||||
await fs.promises.rm(backup, { force: true });
|
await fs.promises.rm(backup, { force: true });
|
||||||
await fs.promises.rm(`${backup}.sig`, { force: true });
|
await fs.promises.rm(`${backup}.sig`, { force: true });
|
||||||
return installed;
|
return installed;
|
||||||
@@ -155,7 +171,7 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
|||||||
if (!local || local.source !== "user") return;
|
if (!local || local.source !== "user") return;
|
||||||
// Grants are only copied for code a trusted key signed. Anything else the
|
// Grants are only copied for code a trusted key signed. Anything else the
|
||||||
// desktop's own admin has to review, as with any plugin dropped on disk.
|
// desktop's own admin has to review, as with any plugin dropped on disk.
|
||||||
if (!local.signedBy) return;
|
if (!vouchedHere(local)) return;
|
||||||
const declared = new Set(local.manifest.capabilities);
|
const declared = new Set(local.manifest.capabilities);
|
||||||
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
||||||
const existing = await repository.listByPlugin(plugin.id);
|
const existing = await repository.listByPlugin(plugin.id);
|
||||||
@@ -196,7 +212,7 @@ async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
|||||||
alertedVersions.add(key);
|
alertedVersions.add(key);
|
||||||
await sendCoreAlert({
|
await sendCoreAlert({
|
||||||
title: "Review a feature from your server",
|
title: "Review a feature from your server",
|
||||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
|
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`,
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
category: "termix.sync.unsigned_plugin",
|
category: "termix.sync.unsigned_plugin",
|
||||||
dedupeKey: `sync:${key}`,
|
dedupeKey: `sync:${key}`,
|
||||||
@@ -239,7 +255,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (wasEnabled) await setPluginEnabled(plugin.id, true);
|
if (wasEnabled && local.source === "bundled") {
|
||||||
|
await setPluginEnabled(plugin.id, true);
|
||||||
|
}
|
||||||
} else if (newer) {
|
} else if (newer) {
|
||||||
await alertOlderDesktop(plugin);
|
await alertOlderDesktop(plugin);
|
||||||
}
|
}
|
||||||
@@ -263,19 +281,19 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await matchGrants(plugin);
|
await matchGrants(plugin);
|
||||||
// Unsigned code is only ever switched off from here, never on.
|
|
||||||
if (!local.signedBy) {
|
|
||||||
const enabledHere = await isEnabledLocally(plugin.id);
|
|
||||||
if (!plugin.enabled && enabledHere) {
|
|
||||||
await setPluginEnabled(plugin.id, false);
|
|
||||||
} else if (plugin.enabled && !enabledHere) {
|
|
||||||
await alertUnsigned(plugin);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!local) return;
|
if (!local) return;
|
||||||
|
// Remote metadata cannot confer bundled trust on a locally loaded user plugin.
|
||||||
|
if (local.source === "user" && !vouchedHere(local)) {
|
||||||
|
const enabledHere = await isEnabledLocally(plugin.id);
|
||||||
|
if (!plugin.enabled && enabledHere) {
|
||||||
|
await setPluginEnabled(plugin.id, false);
|
||||||
|
} else if (plugin.enabled && !enabledHere) {
|
||||||
|
await alertUnsigned(plugin);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
||||||
await setPluginEnabled(plugin.id, plugin.enabled);
|
await setPluginEnabled(plugin.id, plugin.enabled);
|
||||||
} else if (plugin.enabled && local.state !== "active") {
|
} else if (plugin.enabled && local.state !== "active") {
|
||||||
@@ -284,6 +302,7 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||||
|
const { serialized } = await import("../../plugins/manage.js");
|
||||||
const { plugins, registryKeys } = await remoteJson<{
|
const { plugins, registryKeys } = await remoteJson<{
|
||||||
plugins: RemotePlugin[];
|
plugins: RemotePlugin[];
|
||||||
registryKeys?: unknown[];
|
registryKeys?: unknown[];
|
||||||
@@ -295,7 +314,8 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
|||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
try {
|
try {
|
||||||
await mirrorOne(link, plugin);
|
// Share the consent/enable lock so an update cannot swap code mid-approval.
|
||||||
|
await serialized(() => mirrorOne(link, plugin));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncLogger.warn("Could not match a feature to the server", {
|
syncLogger.warn("Could not match a feature to the server", {
|
||||||
operation: "sync_plugin_mirror",
|
operation: "sync_plugin_mirror",
|
||||||
@@ -315,8 +335,13 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a plugin's on/off state belongs to the linked server. */
|
/**
|
||||||
|
* Whether a plugin's on/off state belongs to the linked server. Code no key
|
||||||
|
* trusted here vouches for is the desktop's own admin's call to turn on.
|
||||||
|
*/
|
||||||
export function isServerManaged(pluginId: string): boolean {
|
export function isServerManaged(pluginId: string): boolean {
|
||||||
const local = getPluginRuntime().loader.get(pluginId);
|
const local = getPluginRuntime().loader.get(pluginId);
|
||||||
return (local?.manifest.desktop ?? "mirror") !== "local";
|
const mode = local?.manifest.desktop ?? "mirror";
|
||||||
|
if (mode !== "mirror") return mode === "server";
|
||||||
|
return local?.source !== "user" || vouchedHere(local);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* an entity in without renumbering; anything referenced syncs first.
|
* an entity in without renumbering; anything referenced syncs first.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, getTableColumns } from "drizzle-orm";
|
||||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
import {
|
import {
|
||||||
@@ -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");
|
||||||
@@ -493,6 +497,25 @@ const SHARED_HOST_DROP = [
|
|||||||
...HOST_LOCAL_FIELDS,
|
...HOST_LOCAL_FIELDS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const HOST_TEXT_COLUMNS = new Set(
|
||||||
|
Object.entries(getTableColumns(hosts))
|
||||||
|
.filter(([, column]) => column.dataType === "string")
|
||||||
|
.map(([field]) => field),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A resolved host field back in the form its column stores, as an owned host
|
||||||
|
* sends it: the resolver parses JSON columns and turns the "true"/"false" of
|
||||||
|
* forceKeyboardInteractive into a boolean, which a text column cannot bind.
|
||||||
|
*/
|
||||||
|
function storedForm(field: string, value: unknown): unknown {
|
||||||
|
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
||||||
|
if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||||
const {
|
const {
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
@@ -540,10 +563,7 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
|||||||
const wire: SyncRow = {};
|
const wire: SyncRow = {};
|
||||||
for (const [field, value] of Object.entries(host)) {
|
for (const [field, value] of Object.entries(host)) {
|
||||||
if (SHARED_HOST_DROP.includes(field)) continue;
|
if (SHARED_HOST_DROP.includes(field)) continue;
|
||||||
wire[field] =
|
wire[field] = storedForm(field, value);
|
||||||
value !== null && typeof value === "object"
|
|
||||||
? JSON.stringify(value)
|
|
||||||
: value;
|
|
||||||
}
|
}
|
||||||
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
||||||
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -134,12 +134,23 @@ export async function applyPush(
|
|||||||
const record = await getRecord(userId, op.entityType, op.syncId);
|
const record = await getRecord(userId, op.entityType, op.syncId);
|
||||||
const currentRevision = record?.revision ?? 0;
|
const currentRevision = record?.revision ?? 0;
|
||||||
if (op.baseRevision !== currentRevision) {
|
if (op.baseRevision !== currentRevision) {
|
||||||
|
const row = record?.deleted ? null : (wire.get(key) ?? null);
|
||||||
|
// A live record with no row failed to serialize; a conflict without a
|
||||||
|
// row would read as a delete on the desktop.
|
||||||
|
if (record && !record.deleted && !row) {
|
||||||
|
results.set(key, {
|
||||||
|
...base,
|
||||||
|
status: "rejected",
|
||||||
|
reason: "unavailable",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
results.set(key, {
|
results.set(key, {
|
||||||
...base,
|
...base,
|
||||||
status: "conflict",
|
status: "conflict",
|
||||||
revision: currentRevision,
|
revision: currentRevision,
|
||||||
deleted: !!record?.deleted,
|
deleted: !!record?.deleted,
|
||||||
row: record?.deleted ? null : (wire.get(key) ?? null),
|
row,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
|
|||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||||
|
import { syncLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
getEntity,
|
getEntity,
|
||||||
type RegisteredSyncEntity,
|
type RegisteredSyncEntity,
|
||||||
@@ -229,7 +230,13 @@ export async function loadWireRows(
|
|||||||
);
|
);
|
||||||
const wire = toWire(entity, serialized, syncId);
|
const wire = toWire(entity, serialized, syncId);
|
||||||
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
syncLogger.warn("Could not serialize a sync row", {
|
||||||
|
operation: "sync_serialize",
|
||||||
|
entityType: entity.type,
|
||||||
|
syncId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
failed.add(syncId);
|
failed.add(syncId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration checked for a taken username before inserting, with nothing in
|
||||||
|
* the schema to back it up, so two concurrent sign-ups could both get the same
|
||||||
|
* name. Startup renames the later duplicates instead of deleting anyone, then
|
||||||
|
* makes the name unique.
|
||||||
|
*/
|
||||||
|
describe("users.username uniqueness", () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unique-user-"));
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
process.env.DB_FILE_ENCRYPTION = "false";
|
||||||
|
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
delete process.env.DB_FILE_ENCRYPTION;
|
||||||
|
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeDatabaseWithDuplicates(): void {
|
||||||
|
const seed = new Database(":memory:");
|
||||||
|
seed.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash, registered_at) VALUES
|
||||||
|
('late', 'alice', 'hash', '2025-03-01 00:00:00'),
|
||||||
|
('first', 'alice', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('tie-b', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('tie-a', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||||
|
('only', 'Alice', 'hash', '2025-02-01 00:00:00');
|
||||||
|
`);
|
||||||
|
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||||
|
seed.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootAndGetSqlite(): Promise<Database.Database> {
|
||||||
|
const db = await import("../../../database/db/index.js");
|
||||||
|
await db.initializeDatabase();
|
||||||
|
return db.getSqlite();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps the oldest holder of a name and renames the rest", async () => {
|
||||||
|
writeDatabaseWithDuplicates();
|
||||||
|
|
||||||
|
const sqlite = await bootAndGetSqlite();
|
||||||
|
|
||||||
|
const names = Object.fromEntries(
|
||||||
|
sqlite
|
||||||
|
.prepare("SELECT id, username FROM users")
|
||||||
|
.raw()
|
||||||
|
.all() as [string, string][],
|
||||||
|
);
|
||||||
|
expect(names).toEqual({
|
||||||
|
first: "alice",
|
||||||
|
late: "termix-recovered-0-5",
|
||||||
|
"tie-a": "bob",
|
||||||
|
"tie-b": "termix-recovered-0-3",
|
||||||
|
only: "Alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second user with a taken name", async () => {
|
||||||
|
const sqlite = await bootAndGetSqlite();
|
||||||
|
|
||||||
|
const insert = sqlite.prepare(
|
||||||
|
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
|
||||||
|
);
|
||||||
|
insert.run("u1", "alice", "hash");
|
||||||
|
|
||||||
|
expect(() => insert.run("u2", "alice", "hash")).toThrow(/UNIQUE/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the checked-in migration itself, including occupied recovery prefixes.
|
||||||
|
it("migrates duplicate names without colliding with existing names", () => {
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, registered_at TEXT NOT NULL);
|
||||||
|
INSERT INTO users VALUES
|
||||||
|
('first', 'alice', '2025-01-01'),
|
||||||
|
('late', 'alice', '2025-02-01'),
|
||||||
|
('other', 'alice-late', '2025-01-01'),
|
||||||
|
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||||
|
db.exec(fs.readFileSync("drizzle/sqlite/0067_unique_username.sql", "utf8"));
|
||||||
|
const rows = db.prepare("SELECT username FROM users").pluck().all();
|
||||||
|
expect(new Set(rows).size).toBe(4);
|
||||||
|
expect(rows).toContain("alice");
|
||||||
|
expect(rows).toContain("alice-late");
|
||||||
|
expect(rows).toContain("termix-recovered-0-1");
|
||||||
|
expect(() => db.exec("INSERT INTO users VALUES ('new', 'alice', '2026-01-01')")).toThrow(/UNIQUE/i);
|
||||||
|
} finally { db.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames
|
||||||
|
// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign
|
||||||
|
// key, so uninstalling the plugin used to leave every host write failing with
|
||||||
|
// "no such table: main.p_vault_profiles".
|
||||||
|
describe("ssh_data and the vault plugin's tables", () => {
|
||||||
|
let dataDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-"));
|
||||||
|
vi.resetModules();
|
||||||
|
vi.stubEnv("DATA_DIR", dataDir);
|
||||||
|
vi.stubEnv("DB_FILE_ENCRYPTION", "false");
|
||||||
|
vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seed(afterAdoption: string): void {
|
||||||
|
const seed = new Database(":memory:");
|
||||||
|
seed.exec(
|
||||||
|
fs.readFileSync(
|
||||||
|
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
seed.exec(`
|
||||||
|
CREATE TABLE plugin_migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
migration_id TEXT NOT NULL,
|
||||||
|
checksum TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (plugin_id, migration_id),
|
||||||
|
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('owner', 'alice', 'hash');
|
||||||
|
INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
|
||||||
|
VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password');
|
||||||
|
ALTER TABLE vault_profiles RENAME TO p_vault_profiles;
|
||||||
|
ALTER TABLE vault_tokens RENAME TO p_vault_tokens;
|
||||||
|
${afterAdoption}
|
||||||
|
`);
|
||||||
|
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||||
|
seed.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const db = await import("../../../database/db/index.js");
|
||||||
|
await db.initializeDatabase();
|
||||||
|
return db.getSqlite();
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHosts(sqlite: Database.Database): void {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("repairs a database the plugin was already uninstalled from", async () => {
|
||||||
|
seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||||
|
const sqlite = await boot();
|
||||||
|
|
||||||
|
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves nothing in ssh_data pointing at the plugin", async () => {
|
||||||
|
seed("");
|
||||||
|
const sqlite = await boot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(),
|
||||||
|
).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" }));
|
||||||
|
sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||||
|
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../../database/database.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const ast = ts.createSourceFile(
|
||||||
|
"database.ts",
|
||||||
|
source,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const route = ast.statements.find(
|
||||||
|
(s) =>
|
||||||
|
ts.isExpressionStatement(s) &&
|
||||||
|
ts.isCallExpression(s.expression) &&
|
||||||
|
s.expression.arguments[0]?.getText(ast) === '"/database/import"',
|
||||||
|
) as ts.ExpressionStatement;
|
||||||
|
const batched = (route.expression as ts.CallExpression)
|
||||||
|
.arguments[3] as ts.CallExpression;
|
||||||
|
const callback = batched.arguments[0];
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "termix-import-route-"));
|
||||||
|
|
||||||
|
function exportFile(): string {
|
||||||
|
const path = join(dir, `export-${Math.random()}.db`);
|
||||||
|
const db = new Database(path);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT);
|
||||||
|
CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER);
|
||||||
|
INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin');
|
||||||
|
INSERT INTO ssh_data VALUES
|
||||||
|
(1, '10.0.0.1', 22, 'root', 7),
|
||||||
|
(2, '10.0.0.2', 22, 'root', 8),
|
||||||
|
(3, '10.0.0.3', 22, 'root', 99),
|
||||||
|
(4, '10.0.0.4', 22, 'root', NULL);
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const events: Array<[string, unknown]> = [];
|
||||||
|
const hosts: Array<Record<string, unknown>> = [];
|
||||||
|
const importHostProtocolLogins = vi.fn();
|
||||||
|
const handler = runInNewContext(
|
||||||
|
ts.transpileModule(`(${callback.getText(ast)})`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||||
|
}).outputText,
|
||||||
|
{
|
||||||
|
fs,
|
||||||
|
Buffer,
|
||||||
|
Database,
|
||||||
|
parseUserAgent: () => ({ type: "web" }),
|
||||||
|
createCurrentUserRepository: () => ({ findById: async () => user }),
|
||||||
|
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
|
||||||
|
apiLogger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
createCurrentCredentialRepository: () => ({
|
||||||
|
findIdForImportIdentity: async (_: string, name: string) =>
|
||||||
|
name === "kept" ? 42 : null,
|
||||||
|
createEncryptedForUser: async () => {
|
||||||
|
writes.push("credential");
|
||||||
|
return { id: 100 };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentHostRepository: () => ({
|
||||||
|
existsForImportIdentity: async () => false,
|
||||||
|
createEncryptedForUser: async (_: string, host: never) => {
|
||||||
|
writes.push("host");
|
||||||
|
hosts.push(host);
|
||||||
|
return { id: 500 + hosts.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
legacyStatusCheck: () => ({}),
|
||||||
|
sshOptionsForWrite: () => null,
|
||||||
|
applyHostDefaultsToWrite: async () => ({}),
|
||||||
|
applyPluginHostImportSettings: async () => {},
|
||||||
|
importedHostPluginSettings: () => ({}),
|
||||||
|
importHostProtocolLogins,
|
||||||
|
applyDefaultsAfterHostWrites: async () => {},
|
||||||
|
importUserPluginRows: async () => ({
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: [],
|
||||||
|
}),
|
||||||
|
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||||
|
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||||
|
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() };
|
||||||
|
res.status.mockReturnValue(res);
|
||||||
|
await handler(
|
||||||
|
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
return { res, writes, hosts, events, importHostProtocolLogins };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("points imported hosts at the credentials imported with them", async () => {
|
||||||
|
const { res, writes, hosts, importHostProtocolLogins } =
|
||||||
|
await invoke(exportFile());
|
||||||
|
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual(["credential", "host", "host", "host", "host"]);
|
||||||
|
expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]);
|
||||||
|
expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([
|
||||||
|
[7, 100],
|
||||||
|
[8, 42],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const path = exportFile();
|
||||||
|
const { res } = await invoke(path, null);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the uploaded file when it is not a SQLite database", async () => {
|
||||||
|
const path = join(dir, "not-sqlite.db");
|
||||||
|
writeFileSync(path, "definitely not a database");
|
||||||
|
const { res } = await invoke(path);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the uploaded file after a successful import", async () => {
|
||||||
|
const path = exportFile();
|
||||||
|
await invoke(path);
|
||||||
|
|
||||||
|
expect(existsSync(path)).toBe(false);
|
||||||
|
});
|
||||||
@@ -213,7 +213,7 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
it("checks credential import identity", async () => {
|
it("checks credential import identity", async () => {
|
||||||
const repo = await createRepositories();
|
const repo = await createRepositories();
|
||||||
|
|
||||||
await repo.credentials.create({
|
const created = await repo.credentials.create({
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
name: "primary",
|
name: "primary",
|
||||||
authType: "password",
|
authType: "password",
|
||||||
@@ -221,11 +221,11 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
|
repo.credentials.findIdForImportIdentity("user-1", "primary", "root"),
|
||||||
).resolves.toBe(true);
|
).resolves.toBe(created.id);
|
||||||
await expect(
|
await expect(
|
||||||
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
|
repo.credentials.findIdForImportIdentity("user-1", "primary", "admin"),
|
||||||
).resolves.toBe(false);
|
).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames credential folders through the write boundary", async () => {
|
it("renames credential folders through the write boundary", async () => {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const factory = vi.hoisted(() => ({
|
|
||||||
getCurrentRepositorySqlite: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => factory);
|
|
||||||
|
|
||||||
import {
|
|
||||||
withCurrentSqliteForeignKeysDisabled,
|
|
||||||
withSqliteForeignKeysDisabled,
|
|
||||||
} from "../../../database/repositories/sqlite-foreign-keys.js";
|
|
||||||
|
|
||||||
const previousDatabaseDialect = process.env.DATABASE_DIALECT;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (previousDatabaseDialect === undefined)
|
|
||||||
delete process.env.DATABASE_DIALECT;
|
|
||||||
else process.env.DATABASE_DIALECT = previousDatabaseDialect;
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("withSqliteForeignKeysDisabled", () => {
|
|
||||||
it("restores foreign keys after an import", async () => {
|
|
||||||
const sqlite = { exec: vi.fn() };
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
withSqliteForeignKeysDisabled(sqlite, async () => "imported"),
|
|
||||||
).resolves.toBe("imported");
|
|
||||||
expect(sqlite.exec.mock.calls).toEqual([
|
|
||||||
["PRAGMA foreign_keys = OFF"],
|
|
||||||
["PRAGMA foreign_keys = ON"],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("withCurrentSqliteForeignKeysDisabled", () => {
|
|
||||||
it.each(["postgres", "mysql"])(
|
|
||||||
"runs portable imports with constraints enabled on %s",
|
|
||||||
async (dialect) => {
|
|
||||||
process.env.DATABASE_DIALECT = dialect;
|
|
||||||
const operation = vi.fn().mockResolvedValue("imported");
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
withCurrentSqliteForeignKeysDisabled(operation),
|
|
||||||
).resolves.toBe("imported");
|
|
||||||
expect(operation).toHaveBeenCalledOnce();
|
|
||||||
expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase, testDialect } from "./test-support.js";
|
||||||
|
|
||||||
|
it("migrates duplicate usernames without colliding with occupied recovery names", async () => {
|
||||||
|
const adapter = new TestSqliteDatabase();
|
||||||
|
await adapter.connect();
|
||||||
|
try {
|
||||||
|
await adapter.exec(`CREATE TABLE username_migration_fixture (
|
||||||
|
id varchar(255) PRIMARY KEY, username TEXT NOT NULL, registered_at varchar(255) NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO username_migration_fixture VALUES
|
||||||
|
('first', 'alice', '2025-01-01'),
|
||||||
|
('late', 'alice', '2025-02-01'),
|
||||||
|
('other', 'alice-late', '2025-01-01'),
|
||||||
|
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||||
|
const dialect = testDialect();
|
||||||
|
const number = dialect === "sqlite" ? "0067" : "0072";
|
||||||
|
const migration = fs
|
||||||
|
.readFileSync(`drizzle/${dialect}/${number}_unique_username.sql`, "utf8")
|
||||||
|
.replace(/\busers\b/g, "username_migration_fixture")
|
||||||
|
.replaceAll("idx_users_username", "idx_fixture_username");
|
||||||
|
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||||
|
if (statement.trim()) await adapter.run(sql.raw(statement));
|
||||||
|
}
|
||||||
|
const rows = await adapter.query<{ username: string }>(
|
||||||
|
sql`SELECT username FROM username_migration_fixture`,
|
||||||
|
);
|
||||||
|
const names = rows.map((row) => row.username);
|
||||||
|
expect(new Set(names).size).toBe(4);
|
||||||
|
expect(names).toEqual(
|
||||||
|
expect.arrayContaining(["alice", "alice-late", "termix-recovered-0-1"]),
|
||||||
|
);
|
||||||
|
await expect(async () => {
|
||||||
|
await adapter.run(
|
||||||
|
sql`INSERT INTO username_migration_fixture VALUES ('new', 'alice', '2026-01-01')`,
|
||||||
|
);
|
||||||
|
}).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await adapter.exec("DROP TABLE IF EXISTS username_migration_fixture");
|
||||||
|
await adapter.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { UserRepository } from "../../../database/repositories/user-repository.js";
|
import {
|
||||||
|
UserRepository,
|
||||||
|
isUsernameTaken,
|
||||||
|
} from "../../../database/repositories/user-repository.js";
|
||||||
|
|
||||||
describe("UserRepository.listPage", () => {
|
describe("UserRepository.listPage", () => {
|
||||||
let adapter: TestSqliteDatabase | null = null;
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
@@ -93,3 +96,27 @@ describe("UserRepository.listPage", () => {
|
|||||||
expect(page.total).toBe(5);
|
expect(page.total).toBe(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UserRepository.create", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a taken username in a way callers can recognise", async () => {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const repo = new UserRepository(await adapter.connect());
|
||||||
|
await repo.create({ id: "u1", username: "alice", passwordHash: "hash" });
|
||||||
|
|
||||||
|
const error = await repo
|
||||||
|
.create({ id: "u2", username: "alice", passwordHash: "hash" })
|
||||||
|
.catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(isUsernameTaken(error)).toBe(true);
|
||||||
|
expect(isUsernameTaken(new Error("unrelated"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: () => {},
|
||||||
@@ -202,3 +205,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -176,6 +176,7 @@ const manageMock = vi.hoisted(() => ({
|
|||||||
deletePluginData: vi.fn(),
|
deletePluginData: vi.fn(),
|
||||||
planStateChange: vi.fn(),
|
planStateChange: vi.fn(),
|
||||||
setPluginState: vi.fn(),
|
setPluginState: vi.fn(),
|
||||||
|
enablePluginWithConsent: vi.fn(),
|
||||||
getDeveloperMode: vi.fn(),
|
getDeveloperMode: vi.fn(),
|
||||||
setDeveloperMode: vi.fn(),
|
setDeveloperMode: vi.fn(),
|
||||||
stageUpload: vi.fn(),
|
stageUpload: vi.fn(),
|
||||||
@@ -215,6 +216,12 @@ const onboardingMock = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
||||||
|
|
||||||
|
const syncPluginsMock = vi.hoisted(() => ({
|
||||||
|
isServerManaged: vi.fn((_id: string) => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock);
|
||||||
|
|
||||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||||
.default;
|
.default;
|
||||||
|
|
||||||
@@ -1139,6 +1146,65 @@ describe("plugins route", () => {
|
|||||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("on a linked desktop", () => {
|
||||||
|
const switchOn = (id: string) =>
|
||||||
|
fetch(`${baseUrl}/plugins/${id}/state`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||||
|
state.plugins.set("sample-plugin", makePlugin({ state: "disabled" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to switch a plugin the server decides", async () => {
|
||||||
|
const res = await switchOn("sample-plugin");
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
|
||||||
|
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the desktop's admin switch one the server does not", async () => {
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(() => false);
|
||||||
|
manageMock.enablePluginWithConsent.mockResolvedValue({
|
||||||
|
enable: [],
|
||||||
|
disable: [],
|
||||||
|
});
|
||||||
|
const res = await switchOn("sample-plugin");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith(
|
||||||
|
"sample-plugin",
|
||||||
|
expect.objectContaining({
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["kv:own"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says per plugin whether the server decides it", async () => {
|
||||||
|
state.plugins.set("other", makePlugin({ id: "other" }));
|
||||||
|
syncPluginsMock.isServerManaged.mockImplementation(
|
||||||
|
(id) => id === "other",
|
||||||
|
);
|
||||||
|
const listed = (await (
|
||||||
|
await fetch(`${baseUrl}/plugins`)
|
||||||
|
).json()) as Array<{ id: string; managedByServer: boolean }>;
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])),
|
||||||
|
).toEqual({ "sample-plugin": false, other: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("previews a state change without making it", async () => {
|
it("previews a state change without making it", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin());
|
state.plugins.set("sample-plugin", makePlugin());
|
||||||
manageMock.planStateChange.mockResolvedValue({
|
manageMock.planStateChange.mockResolvedValue({
|
||||||
|
|||||||
@@ -145,6 +145,35 @@ describe("client tunnel runtime authentication", () => {
|
|||||||
expect(sockets).toHaveLength(1);
|
expect(sockets).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the relay's reason when it closes after an error", async () => {
|
||||||
|
const { context, runtimes, sockets } = setup();
|
||||||
|
const runtime = { status: { connected: true } };
|
||||||
|
runtimes.set("web", runtime);
|
||||||
|
const test = context.testC2SRelay(
|
||||||
|
{ name: "web" },
|
||||||
|
"127.0.0.1",
|
||||||
|
8443,
|
||||||
|
null,
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||||
|
sockets[0].emit(
|
||||||
|
"message",
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
error: "Target 127.0.0.1:8443 refused",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
sockets[0].close();
|
||||||
|
expect(await test).toMatchObject({ success: false });
|
||||||
|
expect(runtimes.get("web").status.reason).toBe(
|
||||||
|
"Target 127.0.0.1:8443 refused",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the tunnel connected when one of its connections fails", async () => {
|
it("keeps the tunnel connected when one of its connections fails", async () => {
|
||||||
const { context, runtimes, sockets } = setup();
|
const { context, runtimes, sockets } = setup();
|
||||||
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user