mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
Compare commits
3
Commits
sdk-v1.0.22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c759a147c6 | ||
|
|
cba845a7b8 | ||
|
|
f90621a54e |
No files matched your search
@@ -250,6 +250,27 @@ jobs:
|
|||||||
path: termix_windows_arm64_portable.zip
|
path: termix_windows_arm64_portable.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# The same app with every official plugin, for installs without
|
||||||
|
# internet. Built last so nothing above packs the full set, and with
|
||||||
|
# no update files: a full install updates through the normal build and
|
||||||
|
# downloads the plugins it had on.
|
||||||
|
- name: Build Windows x64 full installer
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 full installer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_x64_full_nsis
|
||||||
|
path: release/termix_windows_x64_full_nsis.exe
|
||||||
|
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'
|
||||||
@@ -488,6 +509,28 @@ jobs:
|
|||||||
path: release/com.karmaa.termix.flatpakref
|
path: release/com.karmaa.termix.flatpakref
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. No update files.
|
||||||
|
- name: Build Linux x64 full packages
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --linux AppImage deb --x64 --publish never \
|
||||||
|
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
|
||||||
|
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux x64 full packages
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_linux_x64_full
|
||||||
|
path: |
|
||||||
|
release/termix_linux_x64_full_appimage.AppImage
|
||||||
|
release/termix_linux_x64_full_deb.deb
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: blacksmith-6vcpu-macos-latest
|
runs-on: blacksmith-6vcpu-macos-latest
|
||||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||||
@@ -763,6 +806,32 @@ jobs:
|
|||||||
|
|
||||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. Signed like the
|
||||||
|
# normal DMG, with no update files.
|
||||||
|
- name: Build macOS universal full DMG
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --mac dmg --universal --publish never \
|
||||||
|
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS universal full DMG
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_macos_universal_full_dmg
|
||||||
|
path: release/termix_macos_universal_full_dmg.dmg
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
- name: Clean up keychains
|
- name: Clean up keychains
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.6
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
||||||
|
- Plugins can say how they show in the mobile app
|
||||||
|
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
||||||
|
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
||||||
|
- A sidebar setting to turn off alternating row colors
|
||||||
|
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
|
||||||
|
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
|
||||||
|
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- body-parser is updated to 2.4.0 for a security fix
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
|
||||||
|
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
|
||||||
|
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
|
||||||
|
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
|
||||||
|
- The macOS menu bar icon shows the Termix logo instead of an empty square
|
||||||
|
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
|
||||||
|
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
|
||||||
|
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
|
||||||
|
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
|
||||||
|
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
|
||||||
|
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
|
||||||
|
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
|
||||||
|
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
|
||||||
|
|
||||||
## 26.10.5
|
## 26.10.5
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
cask "termix" do
|
cask "termix" do
|
||||||
version "26.10.5"
|
version "26.10.6"
|
||||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
sha256 "970d579c438b20fc364912ac53b15f55384cc9b1c169d73320d845af3d9e5be8"
|
||||||
|
|
||||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||||
name "Termix"
|
name "Termix"
|
||||||
|
|||||||
@@ -127,15 +127,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
<td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
<td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||||
<td>Apple App Store | DMG | Homebrew</td>
|
<td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||||
|
|||||||
+34
-34
@@ -17,8 +17,8 @@
|
|||||||
{
|
{
|
||||||
"id": "alerts",
|
"id": "alerts",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
|
||||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
{
|
{
|
||||||
"id": "classic-layout",
|
"id": "classic-layout",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.0/classic-layout-1.1.0.tmxplug",
|
||||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
"sha256": "06a94fdbc2eda2f7cdeb4a6bbdc6adb289d524abfe836d3a32ac9b38ad5f65b9",
|
||||||
"tier": "minimal",
|
"tier": "minimal",
|
||||||
"defaultLook": true
|
"defaultLook": true
|
||||||
},
|
},
|
||||||
@@ -72,15 +72,15 @@
|
|||||||
{
|
{
|
||||||
"id": "databases",
|
"id": "databases",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
|
||||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.2/docker-1.2.2.tmxplug",
|
||||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
"sha256": "2e6ff0a68a70d83267c250547e6a4e4f6282d557b608500309136aca76881abf",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -90,8 +90,8 @@
|
|||||||
{
|
{
|
||||||
"id": "file-manager",
|
"id": "file-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.2/file-manager-1.1.2.tmxplug",
|
||||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
"sha256": "6769e58f23722c06b6e4c187c7ef5ba03f28bbdc19e859104b7a5ead8690c154",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
{
|
{
|
||||||
"id": "fleets",
|
"id": "fleets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
|
||||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
{
|
{
|
||||||
"id": "host-metrics",
|
"id": "host-metrics",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.0/host-metrics-1.3.0.tmxplug",
|
||||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
"sha256": "b38352e4adebf4079918cd7e9e9badfb0a4a9811cc53940852ca71aef860dc26",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
{
|
{
|
||||||
"id": "import-export",
|
"id": "import-export",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
|
||||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
|
||||||
"tier": "standard"
|
"tier": "standard"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,15 +182,15 @@
|
|||||||
{
|
{
|
||||||
"id": "proxmox",
|
"id": "proxmox",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.0/proxmox-1.2.0.tmxplug",
|
||||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
"sha256": "154c9bc1535bb1d05ccad3ab2cb6d848a28ddbfeb6dc589c5683cd7e81ccd3c3",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remote-desktop",
|
"id": "remote-desktop",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.0/remote-desktop-1.3.0.tmxplug",
|
||||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
"sha256": "69177d2a642d549fbd4769833437fdb96065251a4965990b881d1dd583b2832a",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -214,8 +214,8 @@
|
|||||||
{
|
{
|
||||||
"id": "secret-sources",
|
"id": "secret-sources",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.0/secret-sources-1.1.0.tmxplug",
|
||||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
"sha256": "3ba67666a52a38245284bb0294fd7af52a5613921454e66d3b4972727d7ed5c0",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -232,8 +232,8 @@
|
|||||||
{
|
{
|
||||||
"id": "service-manager",
|
"id": "service-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
|
||||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -260,8 +260,8 @@
|
|||||||
{
|
{
|
||||||
"id": "snippets",
|
"id": "snippets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
|
||||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -271,8 +271,8 @@
|
|||||||
{
|
{
|
||||||
"id": "ssh-terminal",
|
"id": "ssh-terminal",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.2.0/ssh-terminal-1.2.0.tmxplug",
|
||||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
"sha256": "629b522ae6ff77a8e033b89bb2efa675ec60c147c378012548feec1a005f9533",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -301,8 +301,8 @@
|
|||||||
{
|
{
|
||||||
"id": "sync",
|
"id": "sync",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
|
||||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
|
||||||
"tier": "standard"
|
"tier": "standard"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -351,8 +351,8 @@
|
|||||||
{
|
{
|
||||||
"id": "tunnels",
|
"id": "tunnels",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug",
|
||||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
"sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -390,8 +390,8 @@
|
|||||||
{
|
{
|
||||||
"id": "webauthn",
|
"id": "webauthn",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
|
||||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`folder` varchar(255) NOT NULL,
|
||||||
|
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,6 +533,20 @@
|
|||||||
"when": 1792430772041,
|
"when": 1792430772041,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792945361809,
|
||||||
|
"tag": "0076_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1793304929772,
|
||||||
|
"tag": "0077_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "shared_folder_auth_overrides" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"folder" varchar(255) NOT NULL,
|
||||||
|
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,6 +533,20 @@
|
|||||||
"when": 1792430770990,
|
"when": 1792430770990,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792945360507,
|
||||||
|
"tag": "0076_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793304928399,
|
||||||
|
"tag": "0077_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`folder` text NOT NULL,
|
||||||
|
`protocol` text DEFAULT 'ssh' NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -498,6 +498,20 @@
|
|||||||
"when": 1792430769899,
|
"when": 1792430769899,
|
||||||
"tag": "0070_session_policy",
|
"tag": "0070_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792945434651,
|
||||||
|
"tag": "0071_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1793304926942,
|
||||||
|
"tag": "0072_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1246,8 +1246,9 @@ function createTray() {
|
|||||||
let trayIcon;
|
let trayIcon;
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
||||||
|
// The logo is full color on a solid tile. As a template image macOS
|
||||||
|
// keeps only its outline, which showed as an empty square.
|
||||||
trayIcon = nativeImage.createFromPath(iconPath);
|
trayIcon = nativeImage.createFromPath(iconPath);
|
||||||
trayIcon.setTemplateImage(true);
|
|
||||||
} else if (process.platform === "win32") {
|
} else if (process.platform === "win32") {
|
||||||
trayIcon = path.join(publicRoot, "icon.ico");
|
trayIcon = path.join(publicRoot, "icon.ico");
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -83,6 +83,30 @@
|
|||||||
"default": "8443",
|
"default": "8443",
|
||||||
"description": "Port for HTTPS."
|
"description": "Port for HTTPS."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_TEMPLATE",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app/nginx/nginx-https.conf.template",
|
||||||
|
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_CONF",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/tmp/nginx/nginx.conf",
|
||||||
|
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_PID",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/tmp/nginx/nginx.pid",
|
||||||
|
"description": "The nginx pid file. Termix only reloads nginx when it exists."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_APP_DIR",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app",
|
||||||
|
"description": "Where Termix is installed, used in place of /app in the nginx template."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "SSL_CERT_PATH",
|
"name": "SSL_CERT_PATH",
|
||||||
"group": "https",
|
"group": "https",
|
||||||
|
|||||||
Generated
+10
-10
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.5",
|
"version": "26.10.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.5",
|
"version": "26.10.6",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.4.0",
|
||||||
"chalk": "^6.0.0",
|
"chalk": "^6.0.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -7470,18 +7470,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.1.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.1",
|
"http-errors": "^2.0.1",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.16.1",
|
||||||
"raw-body": "^3.0.2",
|
"raw-body": "^3.0.2",
|
||||||
"type-is": "^2.1.0"
|
"type-is": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.13",
|
"version": "1.0.22",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.5",
|
"version": "26.10.6",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.4.0",
|
||||||
"chalk": "^6.0.0",
|
"chalk": "^6.0.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.22
|
||||||
|
|
||||||
|
Plugin API 1.13.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
|
||||||
|
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
|
||||||
|
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
|
||||||
|
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
|
||||||
|
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
|
||||||
|
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
|
||||||
|
- `shell.editHost(hostId)` opens the editor of a saved host
|
||||||
|
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
|
||||||
|
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
|
||||||
|
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
|
||||||
|
|
||||||
|
## 1.0.21
|
||||||
|
|
||||||
|
Plugin API 1.12.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
|
||||||
|
|
||||||
## 1.0.20
|
## 1.0.20
|
||||||
|
|
||||||
Plugin API 1.12.0.
|
Plugin API 1.12.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.20 | Plugin API 1.12.0
|
SDK 1.0.22 | Plugin API 1.13.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.
|
||||||
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
|
|
||||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
|
||||||
|
|
||||||
|
### Mobile app
|
||||||
|
|
||||||
|
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
|
||||||
|
plugin as having nothing to show (official plugins are mapped by the app itself).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
|
||||||
|
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
|
||||||
|
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
|
||||||
|
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
|
||||||
|
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
|
||||||
|
| `note` | A locale key or plain text the app shows next to the plugin. |
|
||||||
|
|
||||||
|
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
|
||||||
|
|
||||||
|
How a `webview` plugin runs in the app:
|
||||||
|
|
||||||
|
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
|
||||||
|
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
|
||||||
|
plugin list.
|
||||||
|
- Every request and socket goes through the app, which adds the login, proxy headers and client
|
||||||
|
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
|
||||||
|
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
|
||||||
|
activity). Other core routes answer 403, so call your own backend instead.
|
||||||
|
- Images load straight from the server, without proxy headers or a client certificate.
|
||||||
|
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
|
||||||
|
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
|
||||||
|
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
|
||||||
|
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
|
||||||
|
is too old for the range, it asks the user to update the app instead of opening the view. When the range
|
||||||
|
is for an older API major, it asks to update the plugin. Ask only for the API you use.
|
||||||
|
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
|
||||||
|
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
|
||||||
|
|
||||||
|
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
|
||||||
|
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
|
||||||
|
|
||||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||||
@@ -304,7 +346,7 @@ router.post("/notes", async (req, res) => {
|
|||||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||||
`hosts.edit` checks a core permission.
|
`hosts.edit` checks a core permission.
|
||||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
|
||||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||||
@@ -335,7 +377,9 @@ try {
|
|||||||
|
|
||||||
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
|
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
|
||||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
|
||||||
|
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
|
||||||
|
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||||
|
|
||||||
### The desktop app
|
### The desktop app
|
||||||
|
|
||||||
@@ -451,6 +495,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
|
|||||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||||
|
|
||||||
|
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
|
||||||
|
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
|
||||||
|
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
|
||||||
|
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.sync.registerEntity({
|
||||||
|
type: "sharedIcons",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
order: 70,
|
||||||
|
load: (userId) => iconsVisibleTo(userId),
|
||||||
|
write: (userId, row) => storeCopy(userId, row),
|
||||||
|
erase: (userId, syncId) => dropCopy(userId, syncId),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -905,6 +966,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||||
|
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
|
||||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||||
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||||
|
|||||||
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
|
|||||||
"src/frontend/index.mjs",
|
"src/frontend/index.mjs",
|
||||||
"src/frontend/index.js",
|
"src/frontend/index.js",
|
||||||
];
|
];
|
||||||
|
const MOBILE_ENTRIES = [
|
||||||
|
"src/mobile/index.tsx",
|
||||||
|
"src/mobile/index.ts",
|
||||||
|
"src/mobile/index.mjs",
|
||||||
|
"src/mobile/index.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Builds a bundle that runs in a browser against core's shared modules. */
|
||||||
|
async function buildBrowserBundle(entry, outfile, outDir) {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [entry],
|
||||||
|
outfile,
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
jsx: "automatic",
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: "warning",
|
||||||
|
external: FRONTEND_EXTERNALS,
|
||||||
|
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||||
|
// them too, or code that reads them throws when the plugin loads.
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
"import.meta.env.DEV": "false",
|
||||||
|
"import.meta.env.PROD": "true",
|
||||||
|
"import.meta.env.MODE": '"production"',
|
||||||
|
"import.meta.env.SSR": "false",
|
||||||
|
"import.meta.env":
|
||||||
|
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||||
|
staticUrlImports({ outDir }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
||||||
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
|
|||||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||||
const compiler = () =>
|
const compiler = () =>
|
||||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||||
const scanner = new Scanner({
|
// The mobile bundle uses the same stylesheet.
|
||||||
sources: [
|
const sources = ["frontend", "mobile"]
|
||||||
{
|
.map((dir) => path.join(cwd, "src", dir))
|
||||||
base: path.join(cwd, "src", "frontend"),
|
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||||
pattern: "**/*",
|
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||||
negated: false,
|
const scanner = new Scanner({ sources });
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||||
// on native CSS nesting.
|
// on native CSS nesting.
|
||||||
const emit = async (candidates, options) =>
|
const emit = async (candidates, options) =>
|
||||||
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
|
|||||||
|
|
||||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||||
if (frontendEntry) {
|
if (frontendEntry) {
|
||||||
await esbuild.build({
|
await buildBrowserBundle(
|
||||||
entryPoints: [frontendEntry],
|
frontendEntry,
|
||||||
outfile: path.join(outDir, "frontend.js"),
|
path.join(outDir, "frontend.js"),
|
||||||
bundle: true,
|
outDir,
|
||||||
format: "esm",
|
);
|
||||||
platform: "browser",
|
}
|
||||||
target: "es2022",
|
|
||||||
jsx: "automatic",
|
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||||
sourcemap: true,
|
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||||
logLevel: "warning",
|
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||||
external: FRONTEND_EXTERNALS,
|
if (mobileEntry && typeof mobileOut === "string") {
|
||||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||||
// them too, or code that reads them throws when the plugin loads.
|
|
||||||
define: {
|
|
||||||
"process.env.NODE_ENV": '"production"',
|
|
||||||
"import.meta.env.DEV": "false",
|
|
||||||
"import.meta.env.PROD": "true",
|
|
||||||
"import.meta.env.MODE": '"production"',
|
|
||||||
"import.meta.env.SSR": "false",
|
|
||||||
"import.meta.env":
|
|
||||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
|
||||||
staticUrlImports({ outDir }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
|
|||||||
// A theme only plugin has no frontend code.
|
// A theme only plugin has no frontend code.
|
||||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||||
raw.locales ?? "locales",
|
raw.locales ?? "locales",
|
||||||
|
// Built from src/mobile by termix-plugin build.
|
||||||
|
...(typeof raw.contributes?.mobile?.entry === "string"
|
||||||
|
? [raw.contributes.mobile.entry]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
for (const rel of referenced) {
|
for (const rel of referenced) {
|
||||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.20",
|
"version": "1.0.22",
|
||||||
"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": {
|
||||||
@@ -81,6 +81,10 @@
|
|||||||
"types": "./dist/host-commands.d.ts",
|
"types": "./dist/host-commands.d.ts",
|
||||||
"default": "./dist/host-commands.js"
|
"default": "./dist/host-commands.js"
|
||||||
},
|
},
|
||||||
|
"./host-scripts": {
|
||||||
|
"types": "./dist/host-scripts/index.d.ts",
|
||||||
|
"default": "./dist/host-scripts/index.js"
|
||||||
|
},
|
||||||
"./ssh-certs": {
|
"./ssh-certs": {
|
||||||
"types": "./dist/ssh-certs.d.ts",
|
"types": "./dist/ssh-certs.d.ts",
|
||||||
"default": "./dist/ssh-certs.js"
|
"default": "./dist/ssh-certs.js"
|
||||||
@@ -98,6 +102,10 @@
|
|||||||
"./testing-host": {
|
"./testing-host": {
|
||||||
"default": "./dist/host/testing-host.js"
|
"default": "./dist/host/testing-host.js"
|
||||||
},
|
},
|
||||||
|
"./frame-host": {
|
||||||
|
"default": "./dist/host/frame-host.js"
|
||||||
|
},
|
||||||
|
"./frame-host.css": "./dist/host/frame-host.css",
|
||||||
"./theme.css": "./dist/host/theme.css"
|
"./theme.css": "./dist/host/theme.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -1239,6 +1239,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["mode"],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["webview", "native", "none"],
|
||||||
|
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "\\.js$",
|
||||||
|
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "enum": ["phone", "tablet"] },
|
||||||
|
"description": "webview only. Screen sizes the UI works at. Defaults to both."
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 },
|
||||||
|
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||||
|
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,8 +177,24 @@ export interface SyncWriteEvent {
|
|||||||
export interface SyncEntityRegistration {
|
export interface SyncEntityRegistration {
|
||||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||||
type: string;
|
type: string;
|
||||||
/** The table, as returned by ctx.db.define. */
|
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
|
||||||
table: unknown;
|
table: unknown;
|
||||||
|
/**
|
||||||
|
* Server to desktop only: a desktop never pushes these rows. Required with
|
||||||
|
* `load`.
|
||||||
|
*/
|
||||||
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* Builds the rows the user gets instead of reading the rows they own in
|
||||||
|
* `table`, for data kept for the whole server (a shared library, rows other
|
||||||
|
* users share with them). Rows are on the wire: plaintext, each with a
|
||||||
|
* syncId. Needs `readOnly: true`, `write` and `erase`.
|
||||||
|
*/
|
||||||
|
load?: (userId: string) => Promise<SyncRow[]>;
|
||||||
|
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
|
||||||
|
write?: (userId: string, wire: SyncRow) => Promise<void>;
|
||||||
|
/** Drops the desktop copy with this syncId, for an entity with `load`. */
|
||||||
|
erase?: (userId: string, syncId: string) => Promise<void>;
|
||||||
/** Column holding the owning user id. Defaults to "userId". */
|
/** Column holding the owning user id. Defaults to "userId". */
|
||||||
userColumn?: string;
|
userColumn?: string;
|
||||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||||
@@ -470,6 +486,12 @@ export interface PluginSyncHost {
|
|||||||
ensureCore: () => Promise<void>;
|
ensureCore: () => Promise<void>;
|
||||||
onChanged: (listener: () => void) => () => void;
|
onChanged: (listener: () => void) => () => void;
|
||||||
onLocalWrite: (listener: () => void) => () => void;
|
onLocalWrite: (listener: () => void) => () => void;
|
||||||
|
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
|
||||||
|
idsOf?: (
|
||||||
|
type: string,
|
||||||
|
userId: string,
|
||||||
|
syncIds: string[],
|
||||||
|
) => Promise<Record<string, number>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rows and bookkeeping of one local user's replica. */
|
/** Rows and bookkeeping of one local user's replica. */
|
||||||
@@ -1813,13 +1835,21 @@ export interface PluginNetwork {
|
|||||||
* asked for every connection, so it must be quick. `open` returns a stream
|
* asked for every connection, so it must be quick. `open` returns a stream
|
||||||
* the SSH client runs over, like a relayed socket.
|
* the SSH client runs over, like a relayed socket.
|
||||||
*/
|
*/
|
||||||
|
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
|
||||||
|
export interface PluginSshTransportHost {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
/** Owner of the host row. */
|
||||||
|
userId?: string | null;
|
||||||
|
syncId?: string | null;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
localOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginSshTransportProvider {
|
export interface PluginSshTransportProvider {
|
||||||
claims: (hostId: number) => boolean;
|
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
|
||||||
open: (host: {
|
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
|
||||||
id: number;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
}) => Promise<import("node:stream").Duplex>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSshTransports {
|
export interface PluginSshTransports {
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export interface ShellApi {
|
|||||||
* user reviews and saves it. Absent in some shells.
|
* user reviews and saves it. Absent in some shells.
|
||||||
*/
|
*/
|
||||||
openHostEditor?: (draft?: HostDraft) => void;
|
openHostEditor?: (draft?: HostDraft) => void;
|
||||||
|
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
|
||||||
|
editHost?: (hostId: number) => void;
|
||||||
/** Opens a rail view in the left sidebar. */
|
/** Opens a rail view in the left sidebar. */
|
||||||
openRailView: (id: string) => void;
|
openRailView: (id: string) => void;
|
||||||
/** Closes a rail view wherever it is shown. */
|
/** Closes a rail view wherever it is shown. */
|
||||||
@@ -425,6 +427,11 @@ export interface RailItemContribution {
|
|||||||
* Zero or nothing shows no badge.
|
* Zero or nothing shows no badge.
|
||||||
*/
|
*/
|
||||||
useBadge?: () => number | null | undefined;
|
useBadge?: () => number | null | undefined;
|
||||||
|
/**
|
||||||
|
* A hook for a small status dot on the icon: "ok" (green), "warning" or
|
||||||
|
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
|
||||||
|
*/
|
||||||
|
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelProps {
|
export interface PanelProps {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
|
|
||||||
export type { Client, ClientChannel } from "ssh2";
|
export type { Client, ClientChannel } from "ssh2";
|
||||||
import type { Client, ClientChannel } from "ssh2";
|
import type { Client, ClientChannel } from "ssh2";
|
||||||
|
// Homebrew and OrbStack paths a non-interactive shell misses.
|
||||||
|
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
|
||||||
|
export { EXTRA_PATH_DIRS };
|
||||||
|
|
||||||
export interface ExecResult {
|
export interface ExecResult {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -18,6 +21,30 @@ export interface ExecResult {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
|
||||||
|
* which is what a Chinese-locale Windows console sends (its bytes are almost
|
||||||
|
* never valid UTF-8) and keeps it only when it decodes without replacement
|
||||||
|
* characters. Anything else falls back to lossy UTF-8, the old behavior.
|
||||||
|
*/
|
||||||
|
export function decodeCommandOutput(buf: Buffer): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// not valid UTF-8, try the legacy codepage
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A Node built with small-ICU has no GBK decoder and throws here.
|
||||||
|
const gbk = new TextDecoder("gbk").decode(buf);
|
||||||
|
if (!gbk.includes("�")) return gbk;
|
||||||
|
} catch {
|
||||||
|
// expected - fall through to lossy UTF-8
|
||||||
|
}
|
||||||
|
return buf.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs one non-interactive command over an existing ssh2 client. */
|
/** Runs one non-interactive command over an existing ssh2 client. */
|
||||||
export function execCommand(
|
export function execCommand(
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -62,8 +89,10 @@ export function execCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
stream = _stream;
|
stream = _stream;
|
||||||
let stdout = "";
|
// Chunks are buffered and decoded once on close: a multi-byte character
|
||||||
let stderr = "";
|
// can be split across chunks, and charset detection needs it whole.
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
let exitCode: number | null = null;
|
let exitCode: number | null = null;
|
||||||
|
|
||||||
stream
|
stream
|
||||||
@@ -72,11 +101,15 @@ export function execCommand(
|
|||||||
settled = true;
|
settled = true;
|
||||||
exitCode = typeof code === "number" ? code : null;
|
exitCode = typeof code === "number" ? code : null;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve({ stdout, stderr, code: exitCode });
|
resolve({
|
||||||
|
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
||||||
|
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
||||||
|
code: exitCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
stdout += data.toString("utf8");
|
stdout.push(data);
|
||||||
})
|
})
|
||||||
.on("error", (streamErr: Error) => {
|
.on("error", (streamErr: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
@@ -89,7 +122,7 @@ export function execCommand(
|
|||||||
if (stream.stderr) {
|
if (stream.stderr) {
|
||||||
stream.stderr
|
stream.stderr
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
stderr += data.toString("utf8");
|
stderr.push(data);
|
||||||
})
|
})
|
||||||
.on("error", (stderrErr: Error) => {
|
.on("error", (stderrErr: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
@@ -114,14 +147,6 @@ export interface PlatformInfo {
|
|||||||
osPrettyName: string | null;
|
osPrettyName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
||||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
||||||
* Extend PATH with their common install locations before probing.
|
|
||||||
*/
|
|
||||||
export const EXTRA_PATH_DIRS =
|
|
||||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
|
||||||
|
|
||||||
const DOCKER_PROBE =
|
const DOCKER_PROBE =
|
||||||
`command -v docker >/dev/null 2>&1` +
|
`command -v docker >/dev/null 2>&1` +
|
||||||
` || [ -S /var/run/docker.sock ]` +
|
` || [ -S /var/run/docker.sock ]` +
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
/**
|
||||||
|
* Docker and Podman command builders and output parsers.
|
||||||
|
*
|
||||||
|
* Container ids and names are checked with CONTAINER_ID_RE before they go on
|
||||||
|
* a command line, so they need no quoting. Anything free form is quoted.
|
||||||
|
*/
|
||||||
|
import { HostScriptError } from "./text.js";
|
||||||
|
|
||||||
|
export type ContainerRuntime = "docker" | "podman";
|
||||||
|
|
||||||
|
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||||
|
return value === "podman" ? "podman" : "docker";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
||||||
|
return runtime === "podman" ? "Podman" : "Docker";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||||
|
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||||
|
*/
|
||||||
|
export const EXTRA_PATH_DIRS =
|
||||||
|
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||||
|
|
||||||
|
export function containerCommand(
|
||||||
|
runtime: ContainerRuntime | undefined,
|
||||||
|
args: string,
|
||||||
|
isWindows = false,
|
||||||
|
): string {
|
||||||
|
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
|
||||||
|
// cmd and PowerShell don't understand the POSIX env prefix.
|
||||||
|
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||||
|
|
||||||
|
export function isContainerId(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
value.length <= 256 &&
|
||||||
|
CONTAINER_ID_RE.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireContainer(id: string): string {
|
||||||
|
if (!isContainerId(id))
|
||||||
|
throw new HostScriptError("That is not a valid container id");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTAINER_ACTIONS = [
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"pause",
|
||||||
|
"unpause",
|
||||||
|
] as const;
|
||||||
|
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
||||||
|
|
||||||
|
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
||||||
|
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
||||||
|
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
||||||
|
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
||||||
|
|
||||||
|
export function listFormat(isWindows: boolean): string {
|
||||||
|
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statsFormat(isWindows: boolean): string {
|
||||||
|
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of `ps --format listFormat()`. */
|
||||||
|
export interface ContainerRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
status: string;
|
||||||
|
state: string;
|
||||||
|
ports: string;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLI arguments, without the runtime, for each container command. */
|
||||||
|
export const dockerArgs = {
|
||||||
|
version: () => "--version",
|
||||||
|
ping: () => "ps",
|
||||||
|
list: (all: boolean, isWindows = false) =>
|
||||||
|
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
|
||||||
|
inspect: (id: string) => `inspect ${requireContainer(id)}`,
|
||||||
|
stats: (id: string, isWindows = false) =>
|
||||||
|
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
|
||||||
|
action: (action: ContainerAction, id: string) => {
|
||||||
|
if (!CONTAINER_ACTIONS.includes(action))
|
||||||
|
throw new HostScriptError("Unknown action");
|
||||||
|
return `${action} ${requireContainer(id)}`;
|
||||||
|
},
|
||||||
|
remove: (id: string, force: boolean) =>
|
||||||
|
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
|
||||||
|
logs: (
|
||||||
|
id: string,
|
||||||
|
options: {
|
||||||
|
tail?: number;
|
||||||
|
timestamps?: boolean;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
let args = `logs ${requireContainer(id)}`;
|
||||||
|
if (options.tail && options.tail > 0)
|
||||||
|
args += ` --tail ${Math.floor(options.tail)}`;
|
||||||
|
if (options.timestamps) args += " --timestamps";
|
||||||
|
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
|
||||||
|
args += ` --since ${options.since}`;
|
||||||
|
}
|
||||||
|
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
|
||||||
|
args += ` --until ${options.until}`;
|
||||||
|
}
|
||||||
|
return `${args} 2>&1`;
|
||||||
|
},
|
||||||
|
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
|
||||||
|
composeProjects: () => "compose ls -a --format json",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The version from `docker --version` or `podman --version`. */
|
||||||
|
export function parseRuntimeVersion(output: string): string {
|
||||||
|
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeProblem =
|
||||||
|
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
|
||||||
|
|
||||||
|
/** Sorts a failed `docker ps` into a reason the UI can explain. */
|
||||||
|
export function classifyRuntimeError(message: string): RuntimeProblem {
|
||||||
|
if (
|
||||||
|
/command not found|not found: (docker|podman)|No such file or directory/i.test(
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "NOT_INSTALLED";
|
||||||
|
}
|
||||||
|
if (message.includes("Cannot connect to the Docker daemon"))
|
||||||
|
return "DAEMON_NOT_RUNNING";
|
||||||
|
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
|
||||||
|
return "DOCKER_ERROR";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
||||||
|
export function parseContainerList(output: string): Record<string, unknown>[] {
|
||||||
|
return output
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.flatMap((line) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line) as Record<string, unknown>];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStats {
|
||||||
|
cpu: string;
|
||||||
|
memoryUsed: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
memoryPercent: string;
|
||||||
|
netInput: string;
|
||||||
|
netOutput: string;
|
||||||
|
blockRead: string;
|
||||||
|
blockWrite: string;
|
||||||
|
pids: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
|
||||||
|
export function parseContainerStats(output: string): ContainerStats {
|
||||||
|
const raw = JSON.parse(output.trim()) as Record<string, string>;
|
||||||
|
const split = (value: string | undefined) => {
|
||||||
|
const [first, second] = (value ?? "").split(" / ");
|
||||||
|
return [first?.trim() || "0B", second?.trim() || "0B"];
|
||||||
|
};
|
||||||
|
const [memoryUsed, memoryLimit] = split(raw.memory);
|
||||||
|
const [netInput, netOutput] = split(raw.netIO);
|
||||||
|
const [blockRead, blockWrite] = split(raw.blockIO);
|
||||||
|
return {
|
||||||
|
cpu: raw.cpu,
|
||||||
|
memoryUsed,
|
||||||
|
memoryLimit,
|
||||||
|
memoryPercent: raw.memoryPercent,
|
||||||
|
netInput,
|
||||||
|
netOutput,
|
||||||
|
blockRead,
|
||||||
|
blockWrite,
|
||||||
|
pids: raw.pids,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerState {
|
||||||
|
/** Docker's own state word: running, exited, restarting, ... */
|
||||||
|
state: string;
|
||||||
|
/** Health from the status text, when the image declares a healthcheck. */
|
||||||
|
unhealthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||||
|
|
||||||
|
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||||
|
|
||||||
|
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
||||||
|
export function parseContainerStates(
|
||||||
|
output: string,
|
||||||
|
): Map<string, ContainerState> {
|
||||||
|
const states = new Map<string, ContainerState>();
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as {
|
||||||
|
name?: string;
|
||||||
|
state?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
if (!parsed.name) continue;
|
||||||
|
states.set(parsed.name, {
|
||||||
|
state: (parsed.state ?? "").toLowerCase(),
|
||||||
|
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A partial line is not worth failing the whole poll over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events a pair of snapshots implies. A container missing from the
|
||||||
|
* previous snapshot counts as newly seen rather than started, so the first
|
||||||
|
* poll after a restart does not replay every running container.
|
||||||
|
*/
|
||||||
|
export function diffContainerStates(
|
||||||
|
previous: Map<string, ContainerState>,
|
||||||
|
current: Map<string, ContainerState>,
|
||||||
|
): Array<{ container: string; event: DockerEventName }> {
|
||||||
|
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||||
|
for (const [name, now] of current) {
|
||||||
|
const before = previous.get(name);
|
||||||
|
if (!before) continue;
|
||||||
|
if (before.state !== now.state) {
|
||||||
|
if (now.state === "exited")
|
||||||
|
events.push({ container: name, event: "exited" });
|
||||||
|
else if (now.state === "running")
|
||||||
|
events.push({ container: name, event: "started" });
|
||||||
|
else if (now.state === "restarting") {
|
||||||
|
events.push({ container: name, event: "restarting" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A container can go unhealthy while it stays up.
|
||||||
|
if (!before.unhealthy && now.unhealthy) {
|
||||||
|
events.push({ container: name, event: "unhealthy" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerCounts {
|
||||||
|
running: number;
|
||||||
|
stopped: number;
|
||||||
|
unhealthy: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
|
||||||
|
export function countContainers(output: string): ContainerCounts {
|
||||||
|
const states = [...parseContainerStates(output).values()];
|
||||||
|
const running = states.filter((s) => s.state === "running").length;
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
stopped: states.length - running,
|
||||||
|
unhealthy: states.filter((s) => s.unhealthy).length,
|
||||||
|
total: states.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose stacks
|
||||||
|
|
||||||
|
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
||||||
|
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
|
||||||
|
export const COMPOSE_FILES = [
|
||||||
|
"compose.yaml",
|
||||||
|
"compose.yml",
|
||||||
|
"docker-compose.yaml",
|
||||||
|
"docker-compose.yml",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STACK_ACTIONS = [
|
||||||
|
"deploy",
|
||||||
|
"redeploy",
|
||||||
|
"pull",
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"pause",
|
||||||
|
"unpause",
|
||||||
|
"down",
|
||||||
|
"destroy",
|
||||||
|
] as const;
|
||||||
|
export type StackAction = (typeof STACK_ACTIONS)[number];
|
||||||
|
|
||||||
|
/** Actions that can target one service of a stack. */
|
||||||
|
export const STACK_SERVICE_ACTIONS: StackAction[] = [
|
||||||
|
"deploy",
|
||||||
|
"pull",
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"pause",
|
||||||
|
"unpause",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type StackState =
|
||||||
|
"running" | "partial" | "stopped" | "paused" | "not-deployed";
|
||||||
|
|
||||||
|
export interface StackSummary {
|
||||||
|
name: string;
|
||||||
|
managed: boolean;
|
||||||
|
state: StackState;
|
||||||
|
running: number;
|
||||||
|
total: number;
|
||||||
|
composeFile: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackService {
|
||||||
|
service: string;
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
image: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
health: string;
|
||||||
|
ports: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
|
||||||
|
export function parseJsonLines(output: string): Record<string, unknown>[] {
|
||||||
|
const text = output.trim();
|
||||||
|
if (!text) return [];
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as Record<string, unknown>[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.split("\n").flatMap((line) => {
|
||||||
|
try {
|
||||||
|
return [JSON.parse(line) as Record<string, unknown>];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (value: unknown): string =>
|
||||||
|
typeof value === "string" ? value : "";
|
||||||
|
|
||||||
|
export function parseServices(output: string): StackService[] {
|
||||||
|
return parseJsonLines(output).map((row) => {
|
||||||
|
const publishers = Array.isArray(row.Publishers)
|
||||||
|
? (row.Publishers as Record<string, unknown>[])
|
||||||
|
: [];
|
||||||
|
const ports = publishers
|
||||||
|
.filter((p) => Number(p.PublishedPort) > 0)
|
||||||
|
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.join(", ");
|
||||||
|
return {
|
||||||
|
service: str(row.Service),
|
||||||
|
name: str(row.Name),
|
||||||
|
id: str(row.ID),
|
||||||
|
image: str(row.Image),
|
||||||
|
state: str(row.State),
|
||||||
|
status: str(row.Status),
|
||||||
|
health: str(row.Health),
|
||||||
|
ports,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateOf(running: number, paused: number, total: number): StackState {
|
||||||
|
if (total === 0) return "not-deployed";
|
||||||
|
if (paused === total) return "paused";
|
||||||
|
if (running === total) return "running";
|
||||||
|
if (running > 0) return "partial";
|
||||||
|
return "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
|
||||||
|
export function parseStackStatus(status: string): {
|
||||||
|
state: StackState;
|
||||||
|
running: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
let running = 0;
|
||||||
|
let paused = 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const part of status.split(",")) {
|
||||||
|
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
|
||||||
|
if (!match) continue;
|
||||||
|
const count = Number(match[2]);
|
||||||
|
total += count;
|
||||||
|
if (match[1] === "running") running += count;
|
||||||
|
if (match[1] === "paused") paused += count;
|
||||||
|
}
|
||||||
|
return { state: stateOf(running, paused, total), running, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stackStateFromServices(services: StackService[]) {
|
||||||
|
const total = services.length;
|
||||||
|
const running = services.filter((s) => s.state === "running").length;
|
||||||
|
const paused = services.filter((s) => s.state === "paused").length;
|
||||||
|
return { state: stateOf(running, paused, total), running, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
|
||||||
|
export function parseComposeProjects(output: string): StackSummary[] {
|
||||||
|
return parseJsonLines(output)
|
||||||
|
.flatMap((project) => {
|
||||||
|
const name = str(project.Name);
|
||||||
|
if (!STACK_NAME_RE.test(name)) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
managed: false,
|
||||||
|
...parseStackStatus(str(project.Status)),
|
||||||
|
composeFile: str(project.ConfigFiles).split(",")[0] || null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The compose commands one action runs, in order. */
|
||||||
|
export function composeArgsFor(
|
||||||
|
action: StackAction,
|
||||||
|
service: string | undefined,
|
||||||
|
options: { removeVolumes?: boolean },
|
||||||
|
): string[] {
|
||||||
|
const target = service ? ` ${service}` : "";
|
||||||
|
switch (action) {
|
||||||
|
case "deploy":
|
||||||
|
return [`up -d --remove-orphans${target}`];
|
||||||
|
case "redeploy":
|
||||||
|
return [`pull${target}`, `up -d --remove-orphans${target}`];
|
||||||
|
case "pull":
|
||||||
|
return [`pull${target}`];
|
||||||
|
case "start":
|
||||||
|
case "stop":
|
||||||
|
case "restart":
|
||||||
|
case "pause":
|
||||||
|
case "unpause":
|
||||||
|
return [`${action}${target}`];
|
||||||
|
case "down":
|
||||||
|
return ["down --remove-orphans"];
|
||||||
|
case "destroy":
|
||||||
|
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Host scripts: command builders and output parsers for Docker, Linux
|
||||||
|
* metrics, systemd, the journal and OpenRC.
|
||||||
|
*
|
||||||
|
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
|
||||||
|
* plugin backend and in the mobile app. Run a script by sending it on stdin
|
||||||
|
* to the command from `scriptShell()`.
|
||||||
|
*/
|
||||||
|
export * from "./text.js";
|
||||||
|
export * from "./shell.js";
|
||||||
|
export * from "./docker.js";
|
||||||
|
export * from "./metrics.js";
|
||||||
|
export * from "./systemd.js";
|
||||||
|
export * from "./journal.js";
|
||||||
|
export * from "./openrc.js";
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/** journalctl scripts and parsers. */
|
||||||
|
import { isUnit } from "./systemd.js";
|
||||||
|
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
|
||||||
|
|
||||||
|
export const MAX_JOURNAL_LINES = 5000;
|
||||||
|
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
|
||||||
|
|
||||||
|
export interface JournalQuery {
|
||||||
|
units: string[];
|
||||||
|
priority?: number;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
boot?: string;
|
||||||
|
grep?: string;
|
||||||
|
lines: number;
|
||||||
|
kernel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntry {
|
||||||
|
/** Unix ms. */
|
||||||
|
time: number;
|
||||||
|
priority: number;
|
||||||
|
unit: string;
|
||||||
|
ident: string;
|
||||||
|
pid: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootView {
|
||||||
|
offset: number;
|
||||||
|
id: string;
|
||||||
|
first: string;
|
||||||
|
last: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
|
||||||
|
const WORDS = ["today", "yesterday", "now"];
|
||||||
|
|
||||||
|
export function isJournalTime(value: string): boolean {
|
||||||
|
return (
|
||||||
|
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
|
||||||
|
export function parseJournalQuery(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
): JournalQuery {
|
||||||
|
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
|
||||||
|
const units = rawUnits.map(str).filter(Boolean);
|
||||||
|
for (const unit of units) {
|
||||||
|
if (!isUnit(unit))
|
||||||
|
throw new HostScriptError("That is not a valid unit name");
|
||||||
|
}
|
||||||
|
if (units.length > 20) throw new HostScriptError("Too many units");
|
||||||
|
|
||||||
|
const query: JournalQuery = {
|
||||||
|
units,
|
||||||
|
lines: 300,
|
||||||
|
kernel: input.kernel === "true" || input.kernel === true,
|
||||||
|
};
|
||||||
|
const priority = str(input.priority);
|
||||||
|
if (priority) {
|
||||||
|
const value = Number(priority);
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 7) {
|
||||||
|
throw new HostScriptError("priority must be 0 to 7");
|
||||||
|
}
|
||||||
|
query.priority = value;
|
||||||
|
}
|
||||||
|
for (const key of ["since", "until"] as const) {
|
||||||
|
const value = str(input[key]);
|
||||||
|
if (!value) continue;
|
||||||
|
if (!isJournalTime(value))
|
||||||
|
throw new HostScriptError(`${key} is not a valid time`);
|
||||||
|
query[key] = value;
|
||||||
|
}
|
||||||
|
const boot = str(input.boot);
|
||||||
|
if (boot) {
|
||||||
|
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
|
||||||
|
throw new HostScriptError("boot is not valid");
|
||||||
|
query.boot = boot;
|
||||||
|
}
|
||||||
|
const grep = typeof input.grep === "string" ? input.grep : "";
|
||||||
|
if (grep) {
|
||||||
|
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
|
||||||
|
throw new HostScriptError("The search is too long");
|
||||||
|
}
|
||||||
|
query.grep = grep;
|
||||||
|
}
|
||||||
|
const lines = Number(str(input.lines) || 300);
|
||||||
|
if (Number.isFinite(lines) && lines > 0) {
|
||||||
|
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** journalctl arguments for a query, each one quoted. */
|
||||||
|
export function journalArgs(
|
||||||
|
query: Omit<JournalQuery, "lines"> & { lines?: number },
|
||||||
|
options: { follow?: boolean; useGrep?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
|
||||||
|
args.push(`--output-fields=${FIELDS}`);
|
||||||
|
if (options.follow) args.push("-f");
|
||||||
|
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
|
||||||
|
for (const unit of query.units) {
|
||||||
|
if (!isUnit(unit))
|
||||||
|
throw new HostScriptError("That is not a valid unit name");
|
||||||
|
args.push("-u", q(unit));
|
||||||
|
}
|
||||||
|
if (query.kernel) args.push("-k");
|
||||||
|
if (query.priority !== undefined)
|
||||||
|
args.push("-p", String(Math.floor(query.priority)));
|
||||||
|
if (query.since) args.push("--since", q(query.since));
|
||||||
|
if (query.until) args.push("--until", q(query.until));
|
||||||
|
if (query.boot) args.push("-b", q(query.boot));
|
||||||
|
if (query.grep && options.useGrep !== false) {
|
||||||
|
args.push("--case-sensitive=false", "-g", q(query.grep));
|
||||||
|
}
|
||||||
|
return args.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function journalScript(query: JournalQuery): string {
|
||||||
|
// Older journalctl builds have no -g. Then the search runs on our side instead.
|
||||||
|
const noGrep = journalArgs(
|
||||||
|
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
|
||||||
|
{ useGrep: false },
|
||||||
|
);
|
||||||
|
return `
|
||||||
|
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
|
||||||
|
${journalArgs(query)} 2>/tmp/termix-journal.$$
|
||||||
|
RC=$?
|
||||||
|
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
|
||||||
|
rm -f /tmp/termix-journal.$$
|
||||||
|
echo "@@TERMIX_NOGREP@@"
|
||||||
|
${noGrep}
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
|
||||||
|
exit $RC
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
|
||||||
|
}
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `journalctl -o json` line, or null when it is not an entry. */
|
||||||
|
export function parseEntry(line: string): JournalEntry | null {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text.startsWith("{")) return null;
|
||||||
|
let raw: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const micros = Number(raw.__REALTIME_TIMESTAMP);
|
||||||
|
const priority = Number(raw.PRIORITY);
|
||||||
|
return {
|
||||||
|
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
|
||||||
|
priority: Number.isInteger(priority) ? priority : 6,
|
||||||
|
unit: messageOf(raw._SYSTEMD_UNIT),
|
||||||
|
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
|
||||||
|
pid: messageOf(raw._PID),
|
||||||
|
message: messageOf(raw.MESSAGE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJournal(
|
||||||
|
output: string,
|
||||||
|
query: JournalQuery,
|
||||||
|
): JournalEntry[] {
|
||||||
|
const fallback = output.includes("@@TERMIX_NOGREP@@");
|
||||||
|
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
|
||||||
|
let entries = body
|
||||||
|
.split("\n")
|
||||||
|
.map(parseEntry)
|
||||||
|
.filter((entry): entry is JournalEntry => entry !== null);
|
||||||
|
if (fallback && query.grep) {
|
||||||
|
const needle = query.grep.toLowerCase();
|
||||||
|
entries = entries
|
||||||
|
.filter((entry) => entry.message.toLowerCase().includes(needle))
|
||||||
|
.slice(-query.lines);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
|
||||||
|
|
||||||
|
export function parseBoots(output: string): BootView[] {
|
||||||
|
const boots: BootView[] = [];
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const match =
|
||||||
|
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
|
||||||
|
line,
|
||||||
|
);
|
||||||
|
if (!match) continue;
|
||||||
|
boots.push({
|
||||||
|
offset: Number(match[1]),
|
||||||
|
id: match[2],
|
||||||
|
first: match[3],
|
||||||
|
last: match[4] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return boots.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
|
||||||
|
|
||||||
|
export function parseUsage(output: string): string {
|
||||||
|
return /take up (\S+)/.exec(output)?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
|
||||||
|
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
|
||||||
|
|
||||||
|
export function vacuumScript(body: Record<string, unknown>): string {
|
||||||
|
const size = str(body.size);
|
||||||
|
const time = str(body.time);
|
||||||
|
if (!size && !time) throw new HostScriptError("Give a size or an age");
|
||||||
|
if (size && !SIZE_RE.test(size))
|
||||||
|
throw new HostScriptError("Size looks like 500M or 2G");
|
||||||
|
if (time && !AGE_RE.test(time))
|
||||||
|
throw new HostScriptError("Age looks like 7d or 4w");
|
||||||
|
const flags = [
|
||||||
|
size ? `--vacuum-size=${size}` : "",
|
||||||
|
time ? `--vacuum-time=${time}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
/**
|
||||||
|
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
|
||||||
|
* processes, plus df parsing for disks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// One awk reads every /proc file itself, so a sample is one exec of awk instead
|
||||||
|
// of a shell plus six tools. Output sections are split by @@; each process line
|
||||||
|
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
|
||||||
|
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
|
||||||
|
|
||||||
|
/** The live sample command. The page size never changes on a host, so pass it back once known. */
|
||||||
|
export const liveCmd = (pageBytes?: number) =>
|
||||||
|
"awk -v pg=" +
|
||||||
|
(pageBytes ?? '"$(getconf PAGESIZE)"') +
|
||||||
|
" '" +
|
||||||
|
AWK +
|
||||||
|
"' /proc/[0-9]*/stat";
|
||||||
|
|
||||||
|
export interface LiveSample {
|
||||||
|
/** The admin minimum between samples; the client never polls faster. */
|
||||||
|
minIntervalS?: number;
|
||||||
|
cpu: number | null;
|
||||||
|
cpuMix?: { user: number; system: number; iowait: number; steal: number };
|
||||||
|
cores: Array<number | null>;
|
||||||
|
coreIds: string[];
|
||||||
|
memTotalKb: number;
|
||||||
|
memCachedKb: number;
|
||||||
|
memFreeKb: number;
|
||||||
|
memUsedKb: number;
|
||||||
|
swapTotalKb: number;
|
||||||
|
swapUsedKb: number;
|
||||||
|
load: [number, number, number];
|
||||||
|
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
|
||||||
|
count: number;
|
||||||
|
running: number;
|
||||||
|
procs: Array<{
|
||||||
|
pid: number;
|
||||||
|
name: string;
|
||||||
|
cpu: number;
|
||||||
|
memKb: number;
|
||||||
|
state: string;
|
||||||
|
ppid: number;
|
||||||
|
}>;
|
||||||
|
/** Seconds since boot. */
|
||||||
|
uptime?: number;
|
||||||
|
/** Total bytes received and sent on every interface but lo, since boot. */
|
||||||
|
netTotals?: { rx: number; tx: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the next call needs from this one. */
|
||||||
|
export interface LivePrev {
|
||||||
|
at: number;
|
||||||
|
uptime: number;
|
||||||
|
pageBytes: number;
|
||||||
|
cpu: Map<string, { total: number; idle: number }>;
|
||||||
|
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
|
||||||
|
cpuRaw?: number[];
|
||||||
|
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
|
||||||
|
ticks: Map<string, number>;
|
||||||
|
net: Map<string, { rx: number; tx: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total and idle ticks from a /proc/stat `cpu` line. */
|
||||||
|
export function parseCpuLine(
|
||||||
|
cpuLine: string,
|
||||||
|
): { total: number; idle: number } | undefined {
|
||||||
|
const parts = cpuLine.trim().split(/\s+/);
|
||||||
|
if (parts[0] !== "cpu") return undefined;
|
||||||
|
const nums = parts
|
||||||
|
.slice(1)
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
if (nums.length < 4) return undefined;
|
||||||
|
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||||
|
const total = nums.reduce((a, b) => a + b, 0);
|
||||||
|
return { total, idle };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOP_N = 60;
|
||||||
|
|
||||||
|
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
|
||||||
|
export function parseLive(
|
||||||
|
output: string,
|
||||||
|
prev: LivePrev | undefined,
|
||||||
|
at = Date.now(),
|
||||||
|
): { sample: LiveSample; next: LivePrev } {
|
||||||
|
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
|
||||||
|
output.split("@@\n");
|
||||||
|
|
||||||
|
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
|
||||||
|
const cpu = new Map<string, { total: number; idle: number }>();
|
||||||
|
let cpuRaw: number[] | undefined;
|
||||||
|
for (const l of cpuS.split("\n")) {
|
||||||
|
const name = l.split(/\s+/)[0];
|
||||||
|
if (!name.startsWith("cpu")) continue;
|
||||||
|
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
|
||||||
|
const c = parseCpuLine(
|
||||||
|
l
|
||||||
|
.replace(/^cpu\d*/, "cpu")
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 9)
|
||||||
|
.join(" "),
|
||||||
|
);
|
||||||
|
if (c) cpu.set(name, c);
|
||||||
|
}
|
||||||
|
// Matched by name, so an offline core does not shift the ones after it.
|
||||||
|
const pct = (name: string) => {
|
||||||
|
const p = prev?.cpu.get(name);
|
||||||
|
const c = cpu.get(name);
|
||||||
|
if (!p || !c || c.total <= p.total) return null;
|
||||||
|
const used = c.total - p.total - (c.idle - p.idle);
|
||||||
|
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where the busy time went since the last sample, as percent of all ticks.
|
||||||
|
let cpuMix: LiveSample["cpuMix"];
|
||||||
|
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
|
||||||
|
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
|
||||||
|
const total = d.reduce((a, b) => a + b, 0);
|
||||||
|
if (total > 0 && d.every((v) => v >= 0)) {
|
||||||
|
const p = (n: number) => (n / total) * 100;
|
||||||
|
cpuMix = {
|
||||||
|
user: p(d[0] + d[1]),
|
||||||
|
system: p(d[2] + d[5] + d[6]),
|
||||||
|
iowait: p(d[4]),
|
||||||
|
steal: p(d[7]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mem: Record<string, number> = {};
|
||||||
|
for (const l of memS.split("\n")) {
|
||||||
|
const m = l.match(/^(\w+):\s+(\d+)/);
|
||||||
|
if (m) mem[m[1]] = Number(m[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [la = "", up = ""] = loadS.trim().split("\n");
|
||||||
|
const laParts = la.split(/\s+/).map(Number);
|
||||||
|
const uptime = Number(up.split(/\s+/)[0]) || 0;
|
||||||
|
|
||||||
|
const nets = new Map<string, { rx: number; tx: number }>();
|
||||||
|
for (const l of netS.split("\n")) {
|
||||||
|
const [name, rest] = l.split(":");
|
||||||
|
if (!rest || name.trim() === "lo") continue;
|
||||||
|
const f = rest.trim().split(/\s+/);
|
||||||
|
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
|
||||||
|
}
|
||||||
|
// A reboot makes uptime go backwards, so rates start again.
|
||||||
|
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
|
||||||
|
const rate = (now: number, before: number | undefined) =>
|
||||||
|
dt > 0 && before !== undefined && now >= before
|
||||||
|
? (now - before) / dt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
|
||||||
|
const pageKb = pageBytes / 1024;
|
||||||
|
const aggDelta =
|
||||||
|
prev && cpu.has("cpu") && prev.cpu.has("cpu")
|
||||||
|
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
|
||||||
|
: 0;
|
||||||
|
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
|
||||||
|
const ncpu = Math.max(1, cores.length);
|
||||||
|
const ticks = new Map<string, number>();
|
||||||
|
const procs: LiveSample["procs"] = [];
|
||||||
|
for (const l of procS.split("\n")) {
|
||||||
|
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
|
||||||
|
const name = rest.join("\t");
|
||||||
|
if (!name) continue;
|
||||||
|
const key = pid + ":" + start;
|
||||||
|
const tk = Number(t) || 0;
|
||||||
|
ticks.set(key, tk);
|
||||||
|
const before = prev?.ticks.get(key);
|
||||||
|
procs.push({
|
||||||
|
pid: Number(pid),
|
||||||
|
name,
|
||||||
|
cpu:
|
||||||
|
before !== undefined && aggDelta > 0 && tk >= before
|
||||||
|
? ((tk - before) / aggDelta) * ncpu * 100
|
||||||
|
: 0,
|
||||||
|
memKb: (Number(rss) || 0) * pageKb,
|
||||||
|
state: state || "?",
|
||||||
|
ppid: Number(ppid) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
|
||||||
|
|
||||||
|
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
|
||||||
|
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
|
||||||
|
throw new Error("live sample unavailable: /proc data missing");
|
||||||
|
}
|
||||||
|
const memTotalKb = mem.MemTotal;
|
||||||
|
const swapTotalKb = mem.SwapTotal ?? 0;
|
||||||
|
let rxTotal = 0;
|
||||||
|
let txTotal = 0;
|
||||||
|
for (const c of nets.values()) {
|
||||||
|
rxTotal += c.rx;
|
||||||
|
txTotal += c.tx;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sample: {
|
||||||
|
cpu: pct("cpu"),
|
||||||
|
cores: cores.map(pct),
|
||||||
|
coreIds: cores.map((k) => k.slice(3)),
|
||||||
|
cpuMix,
|
||||||
|
memTotalKb,
|
||||||
|
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
|
||||||
|
memFreeKb: mem.MemFree ?? 0,
|
||||||
|
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
|
||||||
|
swapTotalKb,
|
||||||
|
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
|
||||||
|
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
|
||||||
|
net: [...nets].map(([name, c]) => ({
|
||||||
|
name,
|
||||||
|
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
|
||||||
|
txBps: rate(c.tx, prev?.net.get(name)?.tx),
|
||||||
|
})),
|
||||||
|
count: procs.length,
|
||||||
|
running: procs.filter((p) => p.state === "R").length,
|
||||||
|
procs: procs.slice(0, TOP_N),
|
||||||
|
uptime,
|
||||||
|
netTotals: { rx: rxTotal, tx: txTotal },
|
||||||
|
},
|
||||||
|
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disks
|
||||||
|
|
||||||
|
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
|
||||||
|
const PSEUDO_FS_RE =
|
||||||
|
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
|
||||||
|
|
||||||
|
/** GNU df sizes in KiB, without the header. */
|
||||||
|
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
|
||||||
|
/** GNU df sizes as short human text, without the header. */
|
||||||
|
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
|
||||||
|
|
||||||
|
export interface DfRow {
|
||||||
|
filesystem: string;
|
||||||
|
type: string;
|
||||||
|
mount: string;
|
||||||
|
parts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiskFilesystem {
|
||||||
|
filesystem: string;
|
||||||
|
type: string;
|
||||||
|
mount: string;
|
||||||
|
percent: number | null;
|
||||||
|
usedHuman: string | null;
|
||||||
|
totalHuman: string | null;
|
||||||
|
availableHuman: string | null;
|
||||||
|
usedBytes: number | null;
|
||||||
|
totalBytes: number | null;
|
||||||
|
availableBytes: number | null;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoredMount {
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||||
|
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||||
|
return Number(n.toFixed(digits));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
|
||||||
|
* with Mounted-on last. Pseudo filesystems are left out.
|
||||||
|
*/
|
||||||
|
export function parseDfLines(output: string): DfRow[] {
|
||||||
|
return output
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
return {
|
||||||
|
filesystem: parts[0] || "",
|
||||||
|
type: parts[1] || "",
|
||||||
|
mount: parts[6] || "",
|
||||||
|
parts,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most used real filesystem in a `df -TkP` row set. */
|
||||||
|
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||||
|
index: number;
|
||||||
|
usedBytes: number;
|
||||||
|
totalBytes: number;
|
||||||
|
} {
|
||||||
|
let worstIndex = -1;
|
||||||
|
let worstUsedBytes = -1;
|
||||||
|
let worstTotalBytes = 0;
|
||||||
|
bytesRows.forEach((row, index) => {
|
||||||
|
const totalBytes = Number(row.parts[2]);
|
||||||
|
const usedBytes = Number(row.parts[3]);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(totalBytes) ||
|
||||||
|
!Number.isFinite(usedBytes) ||
|
||||||
|
totalBytes <= 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usedRatio = usedBytes / totalBytes;
|
||||||
|
const worstRatio =
|
||||||
|
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||||
|
if (usedRatio > worstRatio) {
|
||||||
|
worstIndex = index;
|
||||||
|
worstUsedBytes = usedBytes;
|
||||||
|
worstTotalBytes = totalBytes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
index: worstIndex,
|
||||||
|
usedBytes: worstUsedBytes,
|
||||||
|
totalBytes: worstTotalBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
|
||||||
|
* `df -hT -P` human rows into one list. Human rows only supply the display
|
||||||
|
* text, matched by mount point so a mismatched row count can't shift them.
|
||||||
|
*/
|
||||||
|
export function buildFilesystemList(
|
||||||
|
bytesRows: DfRow[],
|
||||||
|
humanRows: DfRow[],
|
||||||
|
blockSizeBytes = 1,
|
||||||
|
): DiskFilesystem[] {
|
||||||
|
const aligned = humanRows.length === bytesRows.length;
|
||||||
|
return bytesRows
|
||||||
|
.map((row, index) => {
|
||||||
|
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
|
||||||
|
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
|
||||||
|
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
|
||||||
|
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
||||||
|
const humanRow = aligned
|
||||||
|
? humanRows[index]
|
||||||
|
: humanRows.find((h) => h.mount === row.mount);
|
||||||
|
const percent = Number.isFinite(usedBytes)
|
||||||
|
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
filesystem: row.filesystem,
|
||||||
|
type: row.type,
|
||||||
|
mount: row.mount,
|
||||||
|
percent: toFixedNum(percent, 0),
|
||||||
|
usedHuman: humanRow?.parts[3] || null,
|
||||||
|
totalHuman: humanRow?.parts[2] || null,
|
||||||
|
availableHuman: humanRow?.parts[4] || null,
|
||||||
|
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
||||||
|
totalBytes,
|
||||||
|
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The headline disk is the root filesystem. On macOS "/" is the small sealed
|
||||||
|
* system volume, so the data volume wins there. With no root mount the most
|
||||||
|
* used one is shown.
|
||||||
|
*/
|
||||||
|
export function selectPrimaryFilesystem(
|
||||||
|
filesystems: DiskFilesystem[],
|
||||||
|
): DiskFilesystem | null {
|
||||||
|
if (filesystems.length === 0) return null;
|
||||||
|
const root =
|
||||||
|
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
|
||||||
|
filesystems.find((fs) => fs.mount === "/");
|
||||||
|
if (root) return root;
|
||||||
|
let best = filesystems[0];
|
||||||
|
for (const fs of filesystems) {
|
||||||
|
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
|
||||||
|
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
|
||||||
|
if (ratio > bestRatio) best = fs;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
|
||||||
|
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
|
||||||
|
*/
|
||||||
|
export function filterExcludedFilesystems(
|
||||||
|
filesystems: DiskFilesystem[],
|
||||||
|
excludedMounts?: string[] | null,
|
||||||
|
): DiskFilesystem[] {
|
||||||
|
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
|
||||||
|
const normalized = excludedMounts
|
||||||
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length === 0) return filesystems;
|
||||||
|
return filesystems.filter((fs) => {
|
||||||
|
const mount = fs.mount.toLowerCase();
|
||||||
|
const type = fs.type.toLowerCase();
|
||||||
|
return !normalized.some(
|
||||||
|
(entry) => mount === entry || (type && type.includes(entry)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMonitoredFilesystems(
|
||||||
|
filesystems: DiskFilesystem[],
|
||||||
|
monitored: MonitoredMount[],
|
||||||
|
customFilesystems: Array<DiskFilesystem | null>,
|
||||||
|
): DiskFilesystem[] {
|
||||||
|
const result = [...filesystems];
|
||||||
|
monitored.forEach((entry, index) => {
|
||||||
|
const path = entry.path.trim();
|
||||||
|
const custom = customFilesystems[index];
|
||||||
|
if (!path || !custom) return;
|
||||||
|
const existing = result.find((fs) => fs.mount === path);
|
||||||
|
if (existing) {
|
||||||
|
existing.label = entry.label?.trim() || undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
...custom,
|
||||||
|
mount: path,
|
||||||
|
label: entry.label?.trim() || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
|
||||||
|
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/** OpenRC scripts, with states mapped onto the words systemd uses. */
|
||||||
|
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
|
||||||
|
import { HostScriptError, shellQuote as q } from "./text.js";
|
||||||
|
|
||||||
|
export const OPENRC_LIST_SCRIPT = `
|
||||||
|
rc-status --servicelist 2>/dev/null
|
||||||
|
echo "@@TERMIX_RUNLEVELS@@"
|
||||||
|
rc-update show -v 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
function activeOf(state: string): { active: string; sub: string } {
|
||||||
|
switch (state) {
|
||||||
|
case "started":
|
||||||
|
return { active: "active", sub: "running" };
|
||||||
|
case "crashed":
|
||||||
|
return { active: "failed", sub: "crashed" };
|
||||||
|
case "starting":
|
||||||
|
return { active: "activating", sub: "starting" };
|
||||||
|
case "stopping":
|
||||||
|
return { active: "deactivating", sub: "stopping" };
|
||||||
|
case "inactive":
|
||||||
|
case "hotplugged":
|
||||||
|
return { active: "inactive", sub: state };
|
||||||
|
default:
|
||||||
|
return { active: "inactive", sub: state || "stopped" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpenRcList(output: string): UnitSummary[] {
|
||||||
|
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
|
||||||
|
const units = new Map<string, UnitSummary>();
|
||||||
|
for (const line of list.split("\n")) {
|
||||||
|
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const { active, sub } = activeOf(match[2]);
|
||||||
|
units.set(match[1], {
|
||||||
|
name: match[1],
|
||||||
|
type: "service",
|
||||||
|
load: "loaded",
|
||||||
|
active,
|
||||||
|
sub,
|
||||||
|
description: "",
|
||||||
|
fileState: "disabled",
|
||||||
|
preset: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const line of levels.split("\n")) {
|
||||||
|
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const runlevels = match[2].trim();
|
||||||
|
const known = units.get(match[1]) ?? {
|
||||||
|
name: match[1],
|
||||||
|
type: "service",
|
||||||
|
load: "loaded",
|
||||||
|
active: "inactive",
|
||||||
|
sub: "stopped",
|
||||||
|
description: "",
|
||||||
|
fileState: "disabled",
|
||||||
|
preset: "",
|
||||||
|
};
|
||||||
|
known.fileState = runlevels ? "enabled" : "disabled";
|
||||||
|
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
|
||||||
|
units.set(match[1], known);
|
||||||
|
}
|
||||||
|
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireName(name: string): string {
|
||||||
|
if (!isOpenRcName(name))
|
||||||
|
throw new HostScriptError("That is not a valid service name");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRcActionScript(name: string, action: UnitAction): string {
|
||||||
|
const s = q(requireName(name));
|
||||||
|
switch (action) {
|
||||||
|
case "start":
|
||||||
|
case "stop":
|
||||||
|
case "restart":
|
||||||
|
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
|
||||||
|
case "enable":
|
||||||
|
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
|
||||||
|
case "disable":
|
||||||
|
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
|
||||||
|
default:
|
||||||
|
throw new HostScriptError("OpenRC can't do that", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRcStatusScript(name: string): string {
|
||||||
|
const s = q(requireName(name));
|
||||||
|
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* How a script reaches the host: the command line runs a shell that reads
|
||||||
|
* the script from stdin (`bash -s`), so names and values in a script are
|
||||||
|
* never parsed out of argv.
|
||||||
|
*/
|
||||||
|
import { shellQuote, toBase64 } from "./text.js";
|
||||||
|
|
||||||
|
// systemctl and friends often live in sbin, which a normal login skips.
|
||||||
|
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
|
||||||
|
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
|
||||||
|
|
||||||
|
/** Puts sbin on PATH and sets a plain, UTC environment. */
|
||||||
|
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
|
||||||
|
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - `plain`: the login user.
|
||||||
|
* - `auto`: root when it can be had without a password, else the login user.
|
||||||
|
* - `root`: root or passwordless sudo, fails on anything else.
|
||||||
|
* - `root-only`: root logins only, for hosts where sudo is turned off.
|
||||||
|
*/
|
||||||
|
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
|
||||||
|
|
||||||
|
const ERR = "SCRIPT_ERR:";
|
||||||
|
|
||||||
|
/** The command line that runs a script sent on stdin. */
|
||||||
|
export function scriptShell(mode: ScriptShellMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case "plain":
|
||||||
|
return `${SCRIPT_PRELUDE}exec bash -s`;
|
||||||
|
case "auto":
|
||||||
|
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
|
||||||
|
case "root":
|
||||||
|
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
|
||||||
|
case "root-only":
|
||||||
|
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a script as root with a sudo password. The command is `plain`; all of
|
||||||
|
* the returned stdin goes to the host. The password is read by the first
|
||||||
|
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
|
||||||
|
* never in the environment and never run as a command. Root logins and
|
||||||
|
* passwordless sudo skip the password. The script itself travels as base64.
|
||||||
|
*/
|
||||||
|
export function passwordSudoScript(
|
||||||
|
script: string,
|
||||||
|
password: string,
|
||||||
|
): { command: string; stdin: string } {
|
||||||
|
if (/[\r\n\0]/.test(password)) {
|
||||||
|
throw new Error("A sudo password can't contain a line break");
|
||||||
|
}
|
||||||
|
const run = `bash -c "$TERMIX_S"`;
|
||||||
|
const stdin = `IFS= read -r TERMIX_PW
|
||||||
|
${password}
|
||||||
|
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
|
||||||
|
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
|
||||||
|
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
|
||||||
|
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
|
||||||
|
fi
|
||||||
|
`;
|
||||||
|
return { command: scriptShell("plain"), stdin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sudo asked for a password or a terminal it did not get. */
|
||||||
|
export const SUDO_REFUSED_RE =
|
||||||
|
/password is required|a terminal is required|no tty present/i;
|
||||||
|
/** sudo got a password and it was wrong. */
|
||||||
|
export const SUDO_WRONG_RE =
|
||||||
|
/incorrect password|sorry, try again|no password was provided/i;
|
||||||
|
/** The user may not use sudo at all. */
|
||||||
|
export const NOT_SUDOER_RE =
|
||||||
|
/is not in the sudoers file|not allowed to run sudo/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
|
||||||
|
* non zero exit to explain a failure.
|
||||||
|
*/
|
||||||
|
export function scriptError(stdout: string): string {
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
if (line.startsWith(ERR)) return line.slice(ERR.length);
|
||||||
|
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((line): line is string => line !== null)
|
||||||
|
.join("\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line a script prints to explain why it stops. */
|
||||||
|
export function errLine(message: string): string {
|
||||||
|
return `echo ${shellQuote(ERR + message)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a long command in the background and stops it once stdin closes,
|
||||||
|
* which happens when the SSH channel ends. A pty can't be trusted for this:
|
||||||
|
* sudo keeps its own and the command would outlive the viewer.
|
||||||
|
*/
|
||||||
|
export function watchdog(command: string): string {
|
||||||
|
return `${command} &
|
||||||
|
P=$!
|
||||||
|
trap 'kill $P 2>/dev/null' EXIT
|
||||||
|
cat >/dev/null
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wrapper a streamed command runs in, as one shell command line. */
|
||||||
|
export function streamCommand(script: string, sudo: boolean): string {
|
||||||
|
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
|
||||||
|
return sudo
|
||||||
|
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
|
||||||
|
: `exec bash -c ${quoted}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* systemd unit scripts and parsers, plus init system detection.
|
||||||
|
* Every script is meant for `scriptShell()`, sent on stdin.
|
||||||
|
*/
|
||||||
|
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
|
||||||
|
|
||||||
|
export const UNIT_TYPES = [
|
||||||
|
"service",
|
||||||
|
"socket",
|
||||||
|
"timer",
|
||||||
|
"target",
|
||||||
|
"mount",
|
||||||
|
"automount",
|
||||||
|
"path",
|
||||||
|
"swap",
|
||||||
|
"slice",
|
||||||
|
"scope",
|
||||||
|
"device",
|
||||||
|
] as const;
|
||||||
|
export type UnitType = (typeof UNIT_TYPES)[number];
|
||||||
|
|
||||||
|
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
|
||||||
|
const UNIT_RE = new RegExp(
|
||||||
|
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
|
||||||
|
);
|
||||||
|
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
|
||||||
|
|
||||||
|
export function isUnit(name: unknown): name is string {
|
||||||
|
return typeof name === "string" && UNIT_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unitType(name: string): UnitType {
|
||||||
|
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Units a file may be written for. Templates and device style names are out. */
|
||||||
|
export function isWritableUnitName(name: string): boolean {
|
||||||
|
return (
|
||||||
|
isUnit(name) &&
|
||||||
|
!name.includes("\\") &&
|
||||||
|
["service", "socket", "timer", "target", "path", "mount"].includes(
|
||||||
|
unitType(name),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenRcName(name: unknown): name is string {
|
||||||
|
return typeof name === "string" && OPENRC_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireUnit(name: unknown): string {
|
||||||
|
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InitSystem = "systemd" | "openrc" | "none";
|
||||||
|
|
||||||
|
/** A unit name or, on OpenRC, a service name. */
|
||||||
|
export function requireService(name: unknown, init: string): string {
|
||||||
|
if (init === "openrc") {
|
||||||
|
if (!isOpenRcName(name))
|
||||||
|
throw new HostScriptError("That is not a valid service name");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return requireUnit(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitSummary {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
load: string;
|
||||||
|
active: string;
|
||||||
|
sub: string;
|
||||||
|
description: string;
|
||||||
|
/** enabled, disabled, static, masked and so on. Empty when unknown. */
|
||||||
|
fileState: string;
|
||||||
|
preset: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnitDetail {
|
||||||
|
name: string;
|
||||||
|
props: Record<string, string>;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimerView {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
active: string;
|
||||||
|
fileState: string;
|
||||||
|
/** Unix ms, or null when not set. */
|
||||||
|
next: number | null;
|
||||||
|
last: number | null;
|
||||||
|
activates: string;
|
||||||
|
schedule: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNIT_ACTIONS = [
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"reload",
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
"enable-now",
|
||||||
|
"disable-now",
|
||||||
|
"mask",
|
||||||
|
"unmask",
|
||||||
|
"reset-failed",
|
||||||
|
"revert",
|
||||||
|
] as const;
|
||||||
|
export type UnitAction = (typeof UNIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
/** Actions OpenRC can do. */
|
||||||
|
export const OPENRC_ACTIONS: readonly UnitAction[] = [
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DETECT_SCRIPT = `
|
||||||
|
INIT=none
|
||||||
|
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
|
||||||
|
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
|
||||||
|
echo "init=$INIT"
|
||||||
|
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
|
||||||
|
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
|
||||||
|
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
|
||||||
|
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
|
||||||
|
echo "uid=$(id -u)"
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
|
||||||
|
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
|
||||||
|
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface Detected {
|
||||||
|
init: InitSystem;
|
||||||
|
version: string;
|
||||||
|
os: string;
|
||||||
|
hostname: string;
|
||||||
|
root: boolean;
|
||||||
|
journal: boolean;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDetect(output: string): Detected {
|
||||||
|
const keys = parseKeys(output);
|
||||||
|
const init =
|
||||||
|
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
version: keys.version ?? "",
|
||||||
|
os: keys.os ?? "",
|
||||||
|
hostname: keys.hostname ?? "",
|
||||||
|
root: keys.uid === "0",
|
||||||
|
journal: keys.journal === "1",
|
||||||
|
failed: Number(keys.failed) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Types the list can show. */
|
||||||
|
export const LIST_TYPES = [
|
||||||
|
"service",
|
||||||
|
"socket",
|
||||||
|
"timer",
|
||||||
|
"target",
|
||||||
|
"path",
|
||||||
|
"mount",
|
||||||
|
] as const;
|
||||||
|
export type ListType = (typeof LIST_TYPES)[number] | "all";
|
||||||
|
|
||||||
|
export function listUnitsScript(type: ListType): string {
|
||||||
|
const types = type === "all" ? LIST_TYPES.join(",") : type;
|
||||||
|
if (type !== "all" && !LIST_TYPES.includes(type))
|
||||||
|
throw new HostScriptError("Unknown unit type");
|
||||||
|
return `
|
||||||
|
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
||||||
|
echo "@@TERMIX_FILES@@"
|
||||||
|
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges list-units and list-unit-files. Template units are left out. */
|
||||||
|
export function parseUnitList(output: string): UnitSummary[] {
|
||||||
|
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
|
||||||
|
const units = new Map<string, UnitSummary>();
|
||||||
|
for (const raw of loaded.split("\n")) {
|
||||||
|
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
|
||||||
|
if (!line) continue;
|
||||||
|
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
const [, name, load, active, sub, description] = match;
|
||||||
|
if (!name.includes(".") || name.includes("@.")) continue;
|
||||||
|
units.set(name, {
|
||||||
|
name,
|
||||||
|
type: unitType(name),
|
||||||
|
load,
|
||||||
|
active,
|
||||||
|
sub,
|
||||||
|
description: description.trim(),
|
||||||
|
fileState: "",
|
||||||
|
preset: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const raw of files.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const [name, state = "", preset = ""] = line.split(/\s+/);
|
||||||
|
if (!name || !name.includes(".") || name.includes("@.")) continue;
|
||||||
|
const known = units.get(name);
|
||||||
|
const cleanPreset = preset === "-" ? "" : preset;
|
||||||
|
if (known) {
|
||||||
|
known.fileState = state;
|
||||||
|
known.preset = cleanPreset;
|
||||||
|
} else {
|
||||||
|
units.set(name, {
|
||||||
|
name,
|
||||||
|
type: unitType(name),
|
||||||
|
load: state === "masked" ? "masked" : "unloaded",
|
||||||
|
active: "inactive",
|
||||||
|
sub: "dead",
|
||||||
|
description: "",
|
||||||
|
fileState: state,
|
||||||
|
preset: cleanPreset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DETAIL_PROPS = [
|
||||||
|
"Id",
|
||||||
|
"Description",
|
||||||
|
"LoadState",
|
||||||
|
"ActiveState",
|
||||||
|
"SubState",
|
||||||
|
"UnitFileState",
|
||||||
|
"UnitFilePreset",
|
||||||
|
"Result",
|
||||||
|
"MainPID",
|
||||||
|
"ExecMainStartTimestamp",
|
||||||
|
"ActiveEnterTimestamp",
|
||||||
|
"InactiveEnterTimestamp",
|
||||||
|
"MemoryCurrent",
|
||||||
|
"MemoryPeak",
|
||||||
|
"CPUUsageNSec",
|
||||||
|
"TasksCurrent",
|
||||||
|
"NRestarts",
|
||||||
|
"FragmentPath",
|
||||||
|
"DropInPaths",
|
||||||
|
"Requires",
|
||||||
|
"Wants",
|
||||||
|
"After",
|
||||||
|
"WantedBy",
|
||||||
|
"TriggeredBy",
|
||||||
|
"Triggers",
|
||||||
|
"User",
|
||||||
|
"Group",
|
||||||
|
"Restart",
|
||||||
|
"Type",
|
||||||
|
"ExecStart",
|
||||||
|
"CanStart",
|
||||||
|
"CanStop",
|
||||||
|
"CanReload",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function detailScript(unit: string): string {
|
||||||
|
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
|
||||||
|
return `
|
||||||
|
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
|
||||||
|
echo "@@TERMIX_STATUS@@"
|
||||||
|
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDetail(unit: string, output: string): UnitDetail {
|
||||||
|
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
|
||||||
|
return { name: unit, props: parseKeys(props), status: status.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERBS: Record<UnitAction, string> = {
|
||||||
|
start: "start",
|
||||||
|
stop: "stop",
|
||||||
|
restart: "restart",
|
||||||
|
reload: "reload",
|
||||||
|
enable: "enable",
|
||||||
|
disable: "disable",
|
||||||
|
"enable-now": "enable --now",
|
||||||
|
"disable-now": "disable --now",
|
||||||
|
mask: "mask",
|
||||||
|
unmask: "unmask",
|
||||||
|
"reset-failed": "reset-failed",
|
||||||
|
revert: "revert",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The systemctl call for an action. Prints `state=` after it. */
|
||||||
|
export function actionScript(unit: string, action: UnitAction): string {
|
||||||
|
const verb = VERBS[action];
|
||||||
|
if (!verb) throw new HostScriptError("Unknown action");
|
||||||
|
const u = q(requireUnit(unit));
|
||||||
|
return `
|
||||||
|
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
|
||||||
|
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
|
||||||
|
export function parseSystemdTime(value: string | undefined): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, y, mo, d, h, mi, s] = match.map(Number);
|
||||||
|
return Date.UTC(y, mo - 1, d, h, mi, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIMERS_SCRIPT = `
|
||||||
|
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
|
||||||
|
[ -n "$UNITS" ] || exit 0
|
||||||
|
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
|
||||||
|
exit 0
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Reads systemctl show output for many units, split by blank lines. */
|
||||||
|
export function parseShowBlocks(output: string): Array<Record<string, string>> {
|
||||||
|
const blocks: Array<Record<string, string>> = [];
|
||||||
|
for (const block of output.split(/\n\s*\n/)) {
|
||||||
|
const lines = block.split("\n").filter((line) => line.includes("="));
|
||||||
|
if (!lines.length) continue;
|
||||||
|
// A unit can have several TimersCalendar lines; keep all of them.
|
||||||
|
const keys: Record<string, string> = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
const key = line.slice(0, eq).trim();
|
||||||
|
const value = line.slice(eq + 1).trim();
|
||||||
|
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
|
||||||
|
}
|
||||||
|
blocks.push(keys);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedules(value: string | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const line of value.split("\n")) {
|
||||||
|
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
|
||||||
|
if (match) out.push(`${match[1]}=${match[2]}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimers(output: string): TimerView[] {
|
||||||
|
return parseShowBlocks(output)
|
||||||
|
.filter((keys) => keys.Id?.endsWith(".timer"))
|
||||||
|
.map((keys) => ({
|
||||||
|
name: keys.Id,
|
||||||
|
description: keys.Description ?? "",
|
||||||
|
active: keys.ActiveState ?? "",
|
||||||
|
fileState: keys.UnitFileState ?? "",
|
||||||
|
next: parseSystemdTime(keys.NextElapseUSecRealtime),
|
||||||
|
last: parseSystemdTime(keys.LastTriggerUSec),
|
||||||
|
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
|
||||||
|
schedule: [
|
||||||
|
...schedules(keys.TimersCalendar),
|
||||||
|
...schedules(keys.TimersMonotonic),
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line per unit from systemctl is-active, in the order asked. */
|
||||||
|
export function isActiveScript(units: string[]): string {
|
||||||
|
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIsActive(
|
||||||
|
units: string[],
|
||||||
|
output: string,
|
||||||
|
): Map<string, string> {
|
||||||
|
const lines = output.split("\n").map((line) => line.trim());
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Quoting, encoding and the error type the host scripts share.
|
||||||
|
*
|
||||||
|
* No Node APIs here: this entry also runs in the mobile app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
|
||||||
|
export class HostScriptError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status = 400,
|
||||||
|
readonly extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HostScriptError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a value in single quotes for a POSIX shell. */
|
||||||
|
export function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written by hand because some JS engines (Hermes) have no TextDecoder.
|
||||||
|
|
||||||
|
/** UTF-8 bytes of a string. */
|
||||||
|
export function utf8Encode(text: string): Uint8Array {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const char of text) {
|
||||||
|
const cp = char.codePointAt(0) ?? 0xfffd;
|
||||||
|
if (cp < 0x80) out.push(cp);
|
||||||
|
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
|
||||||
|
else if (cp < 0x10000) {
|
||||||
|
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
|
||||||
|
} else {
|
||||||
|
out.push(
|
||||||
|
0xf0 | (cp >> 18),
|
||||||
|
0x80 | ((cp >> 12) & 63),
|
||||||
|
0x80 | ((cp >> 6) & 63),
|
||||||
|
0x80 | (cp & 63),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uint8Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
|
||||||
|
export function utf8Decode(bytes: ArrayLike<number>): string {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < bytes.length) {
|
||||||
|
const b = bytes[i];
|
||||||
|
let need = 0;
|
||||||
|
let cp = 0;
|
||||||
|
if (b < 0x80) cp = b;
|
||||||
|
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
|
||||||
|
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
|
||||||
|
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
|
||||||
|
else {
|
||||||
|
out += "\ufffd";
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = 1;
|
||||||
|
for (; j <= need; j++) {
|
||||||
|
const next = bytes[i + j];
|
||||||
|
if (next === undefined || (next & 0xc0) !== 0x80) break;
|
||||||
|
cp = (cp << 6) | (next & 63);
|
||||||
|
}
|
||||||
|
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
|
||||||
|
if (
|
||||||
|
j <= need ||
|
||||||
|
cp < tooSmall ||
|
||||||
|
cp > 0x10ffff ||
|
||||||
|
(cp >= 0xd800 && cp < 0xe000)
|
||||||
|
) {
|
||||||
|
out += "\ufffd";
|
||||||
|
i += Math.max(1, j);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += String.fromCodePoint(cp);
|
||||||
|
i += need + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
|
||||||
|
export function toBase64(text: string): string {
|
||||||
|
const bytes = utf8Encode(text);
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const a = bytes[i];
|
||||||
|
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||||
|
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||||
|
const n = (a << 16) | (b << 8) | c;
|
||||||
|
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
|
||||||
|
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
|
||||||
|
out += i + 2 < bytes.length ? B64[n & 63] : "=";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads key=value lines. Only the first = splits a line. */
|
||||||
|
export function parseKeys(output: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const eq = line.indexOf("=");
|
||||||
|
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trims output and keeps it short enough to show. */
|
||||||
|
export function scrub(text: string, max = 600): string {
|
||||||
|
return text.trim().slice(0, max);
|
||||||
|
}
|
||||||
@@ -37,7 +37,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.12.0";
|
export const PLUGIN_API_VERSION = "1.13.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(
|
||||||
@@ -526,6 +526,36 @@ export interface PluginContributions {
|
|||||||
layouts?: PluginLayoutContribution[];
|
layouts?: PluginLayoutContribution[];
|
||||||
/** Themes. Needs the ui:theme capability. */
|
/** Themes. Needs the ui:theme capability. */
|
||||||
themes?: PluginThemeContribution[];
|
themes?: PluginThemeContribution[];
|
||||||
|
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
|
||||||
|
mobile?: PluginMobileContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - "webview": the app shows the plugin's UI from the server in a WebView.
|
||||||
|
* - "native": the app has its own screens for this plugin.
|
||||||
|
* - "none": nothing to show in the app.
|
||||||
|
*/
|
||||||
|
export const MOBILE_MODES = ["webview", "native", "none"] as const;
|
||||||
|
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
|
||||||
|
|
||||||
|
export const MOBILE_SIZES = ["phone", "tablet"] as const;
|
||||||
|
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
|
||||||
|
|
||||||
|
export interface PluginMobileContribution {
|
||||||
|
mode: PluginMobileMode;
|
||||||
|
/**
|
||||||
|
* webview: a bundle built for the app, in the same folder as the frontend
|
||||||
|
* bundle. Unset means the normal frontend runs in a phone sized frame.
|
||||||
|
*/
|
||||||
|
entry?: string;
|
||||||
|
/** webview: screen sizes the UI works at. Defaults to both. */
|
||||||
|
sizes?: PluginMobileSize[];
|
||||||
|
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
|
||||||
|
views?: string[];
|
||||||
|
/** native: the app feature that stands in for it. Defaults to the plugin id. */
|
||||||
|
feature?: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field a protocol login carries besides username and password. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -765,6 +795,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"protocols",
|
"protocols",
|
||||||
"layouts",
|
"layouts",
|
||||||
"themes",
|
"themes",
|
||||||
|
"mobile",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -946,10 +977,135 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
typeof m.id === "string" ? m.id : undefined,
|
typeof m.id === "string" ? m.id : undefined,
|
||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
|
if (isPlainObject(m.contributes)) {
|
||||||
|
validateMobile(
|
||||||
|
m.contributes,
|
||||||
|
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderOf(file: string): string {
|
||||||
|
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
|
||||||
|
return parts.slice(0, -1).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMobile(
|
||||||
|
contributes: Record<string, unknown>,
|
||||||
|
frontend: string,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
const mobile = contributes.mobile;
|
||||||
|
if (mobile === undefined) return;
|
||||||
|
const where = "contributes.mobile";
|
||||||
|
if (!isPlainObject(mobile)) {
|
||||||
|
errors.push(`Field "${where}" must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
mobile,
|
||||||
|
["mode", "entry", "sizes", "views", "feature", "note"],
|
||||||
|
`"${where}"`,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
const mode = mobile.mode;
|
||||||
|
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
|
||||||
|
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of ["entry", "sizes", "views"]) {
|
||||||
|
if (mobile[key] !== undefined && mode !== "webview") {
|
||||||
|
errors.push(`${where}.${key} is only for mode "webview"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mobile.feature !== undefined && mode !== "native") {
|
||||||
|
errors.push(`${where}.feature is only for mode "native"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mobile.entry !== undefined &&
|
||||||
|
requireString(mobile.entry, `${where}.entry`, errors)
|
||||||
|
) {
|
||||||
|
const before = errors.length;
|
||||||
|
requireRelativePath(mobile.entry, `${where}.entry`, errors);
|
||||||
|
if (!mobile.entry.endsWith(".js")) {
|
||||||
|
errors.push(`${where}.entry must be a .js file`);
|
||||||
|
} else if (
|
||||||
|
errors.length === before &&
|
||||||
|
folderOf(mobile.entry) !== folderOf(frontend)
|
||||||
|
) {
|
||||||
|
// Core only serves the folder the frontend bundle sits in.
|
||||||
|
errors.push(
|
||||||
|
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobile.sizes !== undefined) {
|
||||||
|
const sizes = mobile.sizes;
|
||||||
|
if (!Array.isArray(sizes) || sizes.length === 0) {
|
||||||
|
errors.push(`${where}.sizes must be a non-empty array`);
|
||||||
|
} else {
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
for (const size of sizes) {
|
||||||
|
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(size)) {
|
||||||
|
errors.push(`${where}.sizes duplicates "${String(size)}"`);
|
||||||
|
}
|
||||||
|
seen.add(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobile.views !== undefined) {
|
||||||
|
const views = mobile.views;
|
||||||
|
if (!Array.isArray(views) || views.length === 0) {
|
||||||
|
errors.push(`${where}.views must be a non-empty array`);
|
||||||
|
} else {
|
||||||
|
const declared = new Set<string>();
|
||||||
|
for (const field of ["tabs", "panels", "dashboardCards"]) {
|
||||||
|
const list = contributes[field];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const view of list) {
|
||||||
|
if (isPlainObject(view) && typeof view.id === "string") {
|
||||||
|
declared.add(view.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
for (const view of views) {
|
||||||
|
if (typeof view !== "string" || !declared.has(view)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(view)) {
|
||||||
|
errors.push(`${where}.views duplicates "${view}"`);
|
||||||
|
}
|
||||||
|
seen.add(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mobile.feature !== undefined &&
|
||||||
|
(typeof mobile.feature !== "string" ||
|
||||||
|
!LOOK_ID_PATTERN.test(mobile.feature))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mobile.note !== undefined) {
|
||||||
|
requireString(mobile.note, `${where}.note`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateFeatures(features: unknown, errors: string[]): void {
|
function validateFeatures(features: unknown, errors: string[]): void {
|
||||||
if (features === undefined) return;
|
if (features === undefined) return;
|
||||||
if (!Array.isArray(features)) {
|
if (!Array.isArray(features)) {
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ getDeviceId (value): () => string
|
|||||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||||
|
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
|
||||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||||
@@ -526,7 +527,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
|||||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
|
||||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
rightDockableIds (value): () => string[]
|
rightDockableIds (value): () => string[]
|
||||||
@@ -544,6 +545,7 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
|
|||||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||||
|
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
|
||||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Builds the two pieces of the plugin SDK that come from core:
|
* Builds the pieces of the plugin SDK that come from core:
|
||||||
*
|
*
|
||||||
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
||||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
||||||
* dist/host/core-classes.json the classes core's CSS has rules for
|
* dist/host/core-classes.json the classes core's CSS has rules for
|
||||||
|
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
|
||||||
|
* dist/host/frame-host.css core's compiled CSS for that frame
|
||||||
*
|
*
|
||||||
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
||||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||||
@@ -46,6 +48,7 @@ await esbuild.build({
|
|||||||
entryPoints: {
|
entryPoints: {
|
||||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||||
|
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
|
||||||
},
|
},
|
||||||
outdir: outDir,
|
outdir: outDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -72,6 +75,9 @@ await esbuild.build({
|
|||||||
// so the translated locale files it would list are simply none.
|
// so the translated locale files it would list are simply none.
|
||||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||||
define: {
|
define: {
|
||||||
|
// esbuild picks "development" for an unminified build, which sends core's
|
||||||
|
// requests to the Vite dev proxy on any server without a port.
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
"import.meta.glob": "__termixNoGlob",
|
"import.meta.glob": "__termixNoGlob",
|
||||||
"import.meta.env.DEV": "false",
|
"import.meta.env.DEV": "false",
|
||||||
"import.meta.env.PROD": "true",
|
"import.meta.env.PROD": "true",
|
||||||
@@ -157,14 +163,16 @@ const rootSource =
|
|||||||
const scanner = new Scanner({
|
const scanner = new Scanner({
|
||||||
sources: [...rootSource, ...compiler.sources],
|
sources: [...rootSource, ...compiler.sources],
|
||||||
});
|
});
|
||||||
const coreClasses = [
|
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||||
...classNamesInCss(
|
minify: true,
|
||||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
}).code;
|
||||||
),
|
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||||
].sort();
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(outDir, "core-classes.json"),
|
path.join(outDir, "core-classes.json"),
|
||||||
`${JSON.stringify(coreClasses)}\n`,
|
`${JSON.stringify(coreClasses)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The frame host has no Vite build to load core's CSS, so it gets it here.
|
||||||
|
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
|
||||||
|
|
||||||
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||||
@@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
|||||||
const INTERNAL_EXPORTS = new Set([
|
const INTERNAL_EXPORTS = new Set([
|
||||||
"./testing/setup",
|
"./testing/setup",
|
||||||
"./testing-host",
|
"./testing-host",
|
||||||
|
"./frame-host",
|
||||||
|
"./frame-host.css",
|
||||||
"./tsconfig.plugin.json",
|
"./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json",
|
"./schema/manifest.schema.json",
|
||||||
"./theme.css",
|
"./theme.css",
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ const ALLOWED = [
|
|||||||
"getFolderAccess",
|
"getFolderAccess",
|
||||||
"getFolderDefaultsId",
|
"getFolderDefaultsId",
|
||||||
"getHostAccess",
|
"getHostAccess",
|
||||||
|
"getFolderAuthOverride",
|
||||||
"getHostAuthOverride",
|
"getHostAuthOverride",
|
||||||
"getHostDefaultsLevel",
|
"getHostDefaultsLevel",
|
||||||
"getHostEditorSection",
|
"getHostEditorSection",
|
||||||
@@ -565,6 +566,7 @@ const ALLOWED = [
|
|||||||
"saveUserPreferences",
|
"saveUserPreferences",
|
||||||
"setAllPluginChannels",
|
"setAllPluginChannels",
|
||||||
"setDeveloperMode",
|
"setDeveloperMode",
|
||||||
|
"setFolderAuthOverride",
|
||||||
"setHostAuthOverride",
|
"setHostAuthOverride",
|
||||||
"setNotificationPrivateEndpoints",
|
"setNotificationPrivateEndpoints",
|
||||||
"setPluginOptions",
|
"setPluginOptions",
|
||||||
|
|||||||
+31
-5
@@ -404,6 +404,7 @@ function watchBackend() {
|
|||||||
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
||||||
);
|
);
|
||||||
let first = true;
|
let first = true;
|
||||||
|
let restartQueued = false;
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
tsc.stdout.on("data", (chunk) => {
|
tsc.stdout.on("data", (chunk) => {
|
||||||
buffer += chunk.toString();
|
buffer += chunk.toString();
|
||||||
@@ -423,14 +424,21 @@ function watchBackend() {
|
|||||||
? "starting the backend"
|
? "starting the backend"
|
||||||
: "backend changed, restarting it";
|
: "backend changed, restarting it";
|
||||||
first = false;
|
first = false;
|
||||||
void queue(() => restartBackend(reason));
|
if (restartQueued) continue;
|
||||||
|
restartQueued = true;
|
||||||
|
void queue(() => {
|
||||||
|
restartQueued = false;
|
||||||
|
return restartBackend(reason);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchSdk() {
|
function watchSdk() {
|
||||||
|
let buildStarted = Date.now();
|
||||||
const changed = createDebouncer(300, () =>
|
const changed = createDebouncer(300, () =>
|
||||||
queue(async () => {
|
queue(async () => {
|
||||||
|
buildStarted = Date.now();
|
||||||
try {
|
try {
|
||||||
await run("npm", ["run", "build:sdk"]);
|
await run("npm", ["run", "build:sdk"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -440,7 +448,16 @@ function watchSdk() {
|
|||||||
await restartBackend("SDK changed, restarting the backend");
|
await restartBackend("SDK changed, restarting the backend");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
watch(SDK_SRC, true, () => changed("sdk"));
|
// Windows also reports reads (access time), so only a file saved after the last build counts
|
||||||
|
watch(SDK_SRC, true, (_event, filename) => {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
|
||||||
|
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
|
||||||
|
} catch {
|
||||||
|
// deleted or renamed, still a change
|
||||||
|
}
|
||||||
|
changed("sdk");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchElectron() {
|
function watchElectron() {
|
||||||
@@ -580,9 +597,18 @@ function watchPlugins(dir) {
|
|||||||
queue(() => rebuildPlugin(byId.get(id))),
|
queue(() => rebuildPlugin(byId.get(id))),
|
||||||
);
|
);
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
watch(plugin.repo, true, (_event, filename) => {
|
// Only source folders, so node_modules and .git are never watched
|
||||||
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
|
const onEvent = (prefix) => (_event, filename) => {
|
||||||
});
|
const name = prefix
|
||||||
|
? path.join(prefix, String(filename ?? ""))
|
||||||
|
: filename;
|
||||||
|
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
|
||||||
|
};
|
||||||
|
watch(plugin.repo, false, onEvent(""));
|
||||||
|
for (const sub of ["src", "locales", "migrations"]) {
|
||||||
|
const target = path.join(plugin.repo, sub);
|
||||||
|
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFullBuilds(version) {
|
||||||
|
const base = `https://github.com/Termix-SSH/Termix/releases/download/v${version}`;
|
||||||
|
const link = (label, file) => `[${label}](${base}/${file})`;
|
||||||
|
return [
|
||||||
|
"The builds above include the standard plugins. The full builds include every official plugin, which helps on a network with no internet. You can still turn plugins on or off in the Plugins tab. Full builds are x64 only (universal on Mac):",
|
||||||
|
"",
|
||||||
|
`- Windows: ${link("EXE", "termix_windows_x64_full_nsis.exe")}`,
|
||||||
|
`- Linux: ${link("AppImage", "termix_linux_x64_full_appimage.AppImage")} · ${link("DEB", "termix_linux_x64_full_deb.deb")}`,
|
||||||
|
`- Mac: ${link("DMG", "termix_macos_universal_full_dmg.dmg")}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||||
const notes = changelogSection(changelog, version);
|
const notes = changelogSection(changelog, version);
|
||||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||||
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
|||||||
"### Downloads",
|
"### Downloads",
|
||||||
"",
|
"",
|
||||||
buildTable(version, mobileVersion, mobileTag),
|
buildTable(version, mobileVersion, mobileTag),
|
||||||
|
"",
|
||||||
|
buildFullBuilds(version),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ describe("buildBody", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links the full builds", () => {
|
||||||
|
const body = buildBody({
|
||||||
|
version: "26.10.0",
|
||||||
|
mobileVersion: "1.2.0",
|
||||||
|
changelog: CHANGELOG,
|
||||||
|
});
|
||||||
|
expect(body).toContain("v26.10.0/termix_windows_x64_full_nsis.exe");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_appimage.AppImage");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_deb.deb");
|
||||||
|
expect(body).toContain("v26.10.0/termix_macos_universal_full_dmg.dmg");
|
||||||
|
});
|
||||||
|
|
||||||
it("works for the real changelog", () => {
|
it("works for the real changelog", () => {
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const body = buildBody({
|
const body = buildBody({
|
||||||
|
|||||||
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
|||||||
expect(output).not.toContain("is declared but");
|
expect(output).not.toContain("is declared but");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate mobile entry", () => {
|
||||||
|
function mobile(built: boolean) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "fixture",
|
||||||
|
version: "1.0.0",
|
||||||
|
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (built) {
|
||||||
|
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports a mobile entry that was not built", async () => {
|
||||||
|
expect(await problems(mobile(false))).toContain(
|
||||||
|
"manifest references dist/mobile.js, which does not exist",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a built mobile entry", async () => {
|
||||||
|
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -1316,6 +1329,8 @@ const migrateSchema = () => {
|
|||||||
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
||||||
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||||
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
||||||
|
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
|
||||||
|
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const migration of sshDataMigrations) {
|
for (const migration of sshDataMigrations) {
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: int("ssh_port").default(22),
|
sshPort: int("ssh_port").default(22),
|
||||||
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = mysqlTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = mysqlTable(
|
export const sharedHostSecrets = mysqlTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = pgTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
sshPort: integer("ssh_port").default(22),
|
||||||
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = pgTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = pgTable(
|
export const sharedHostSecrets = pgTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
sshPort: integer("ssh_port").default(22),
|
||||||
@@ -477,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = sqliteTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: text("folder").notNull(),
|
||||||
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = sqliteTable(
|
export const sharedHostSecrets = sqliteTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
|||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
|
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||||
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||||
|
return new SharedFolderAuthOverrideRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook(
|
||||||
|
"shared_folder_auth_override_repository_write",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||||
return new SshCredentialUsageRepository(
|
return new SshCredentialUsageRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||||
|
import { sharedFolderAuthOverrides } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { deleteReturning, upsert } from "./returning.js";
|
||||||
|
|
||||||
|
/** "A / B / C" and the folders above it, closest first. */
|
||||||
|
export function folderAndParents(folder: string | null | undefined): string[] {
|
||||||
|
const parts = (folder ?? "")
|
||||||
|
.split(" / ")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = parts.length; i > 0; i--) {
|
||||||
|
out.push(parts.slice(0, i).join(" / "));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SharedFolderAuthOverrideRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findCredentialId(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
|
||||||
|
.from(sharedFolderAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.folder, folder),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0]?.credentialId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login set on the host's folder or the closest folder above it, for
|
||||||
|
* one protocol. Null when none is set.
|
||||||
|
*/
|
||||||
|
async findClosest(
|
||||||
|
userId: string,
|
||||||
|
hostFolder: string | null | undefined,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<{ folder: string; credentialId: number } | null> {
|
||||||
|
const folders = folderAndParents(hostFolder);
|
||||||
|
if (folders.length === 0) return null;
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
folder: sharedFolderAuthOverrides.folder,
|
||||||
|
credentialId: sharedFolderAuthOverrides.credentialId,
|
||||||
|
})
|
||||||
|
.from(sharedFolderAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
inArray(sharedFolderAuthOverrides.folder, folders),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const folder of folders) {
|
||||||
|
const row = rows.find((candidate) => candidate.folder === folder);
|
||||||
|
if (row) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCredential(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsert(
|
||||||
|
this.context,
|
||||||
|
sharedFolderAuthOverrides,
|
||||||
|
{ userId, folder, protocol, credentialId },
|
||||||
|
{
|
||||||
|
target: [
|
||||||
|
sharedFolderAuthOverrides.userId,
|
||||||
|
sharedFolderAuthOverrides.folder,
|
||||||
|
sharedFolderAuthOverrides.protocol,
|
||||||
|
],
|
||||||
|
set: { credentialId, updatedAt: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCredential(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows = await deleteReturning(
|
||||||
|
this.context,
|
||||||
|
sharedFolderAuthOverrides,
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.folder, folder),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (rows.length > 0) await this.onWrite?.();
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color and icon from a host write. A field left out stays as stored, so
|
||||||
|
* editors that don't know about them never clear them. Bad values store null.
|
||||||
|
*/
|
||||||
|
export function normalizeHostLookFields(values: Record<string, unknown>): {
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
} {
|
||||||
|
const result: { color?: string | null; icon?: string | null } = {};
|
||||||
|
if (values.color !== undefined) {
|
||||||
|
result.color =
|
||||||
|
typeof values.color === "string" && HOST_COLOR.test(values.color)
|
||||||
|
? values.color.toLowerCase()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (values.icon !== undefined) {
|
||||||
|
result.icon =
|
||||||
|
typeof values.icon === "string" && HOST_ICON.test(values.icon)
|
||||||
|
? values.icon
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||||
|
|
||||||
export function normalizeProtocolEnableFields(
|
export function normalizeProtocolEnableFields(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
isOptionalAuthType,
|
isOptionalAuthType,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
OWNER_PRIVATE_AUTH_FIELDS,
|
OWNER_PRIVATE_AUTH_FIELDS,
|
||||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||||
@@ -335,6 +336,7 @@ router.post(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -912,6 +914,7 @@ router.put(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||||
* `dependencies` and `optionalDependencies` let the loader activate
|
* `dependencies` and `optionalDependencies` let the loader activate
|
||||||
* frontends in the same order the server does.
|
* frontends in the same order the server does. `engine` holds the
|
||||||
|
* manifest's `api` and `termix` ranges, so other clients (the mobile
|
||||||
|
* app) can tell whether they understand the plugin.
|
||||||
*
|
*
|
||||||
* 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, and, for
|
* only run on that server and stay off here on purpose, and, for
|
||||||
@@ -303,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
|
const engine: { api?: string; termix?: string } = {};
|
||||||
let desktopMode: unknown = loaded
|
let desktopMode: unknown = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
desktop?: unknown;
|
desktop?: unknown;
|
||||||
runtime?: unknown;
|
runtime?: unknown;
|
||||||
|
engine?: { api?: unknown; termix?: unknown };
|
||||||
};
|
};
|
||||||
|
if (typeof manifest?.engine?.api === "string") {
|
||||||
|
engine.api = manifest.engine.api;
|
||||||
|
}
|
||||||
|
if (typeof manifest?.engine?.termix === "string") {
|
||||||
|
engine.termix = manifest.engine.termix;
|
||||||
|
}
|
||||||
desktopMode = loaded
|
desktopMode = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: (manifest?.desktop ??
|
: (manifest?.desktop ??
|
||||||
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
? "safe-mode"
|
? "safe-mode"
|
||||||
: (loaded?.state ?? record.state),
|
: (loaded?.state ?? record.state),
|
||||||
runsOnServer: linked && desktopMode === "server",
|
runsOnServer: linked && desktopMode === "server",
|
||||||
|
engine,
|
||||||
contributes,
|
contributes,
|
||||||
icon,
|
icon,
|
||||||
dependencies,
|
dependencies,
|
||||||
@@ -2695,6 +2706,10 @@ router.put(
|
|||||||
* summary: Upload a .tmxplug to install from a file
|
* summary: Upload a .tmxplug to install from a file
|
||||||
* description: >
|
* description: >
|
||||||
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
||||||
|
The release's .sig may come base64 encoded in the
|
||||||
|
X-Termix-Plugin-Signature header; an official one makes the file
|
||||||
|
install as an official plugin, and it may then fill in a plugin
|
||||||
|
whose download failed.
|
||||||
* The file is checked (archive, manifest, plugin API) and held for 30
|
* The file is checked (archive, manifest, plugin API) and held for 30
|
||||||
* minutes, but nothing is loaded. The answer lists what it asks for so
|
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||||
* the admin can agree, then POST /plugins/upload/{token}/install
|
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||||
@@ -2740,7 +2755,13 @@ router.post(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { stageUpload } = await import("../../plugins/manage.js");
|
const { stageUpload } = await import("../../plugins/manage.js");
|
||||||
res.json(await stageUpload(req.body));
|
// The release's .sig, base64 encoded, so an official file installs as
|
||||||
|
// official (and may write the tables its 2.8 version left).
|
||||||
|
const header = req.get("x-termix-plugin-signature");
|
||||||
|
const signature = header
|
||||||
|
? Buffer.from(header, "base64").toString("utf8")
|
||||||
|
: null;
|
||||||
|
res.json(await stageUpload(req.body, signature));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendManageError(
|
sendManageError(
|
||||||
res,
|
res,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||||
import {
|
import {
|
||||||
assertHostsUnlocked,
|
assertHostsUnlocked,
|
||||||
|
hostLock,
|
||||||
hasHostLocks,
|
hasHostLocks,
|
||||||
requireUnlockedHost,
|
requireUnlockedHost,
|
||||||
sendHostLocked,
|
sendHostLocked,
|
||||||
@@ -36,8 +37,10 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -722,14 +725,40 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The host grants that went to the same user or role as a folder rule. */
|
||||||
|
export function grantsForFolderRule<
|
||||||
|
T extends { userId?: string | null; roleId?: number | null },
|
||||||
|
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
|
||||||
|
return entries.filter((entry) =>
|
||||||
|
rule.userId
|
||||||
|
? entry.userId === rule.userId
|
||||||
|
: rule.roleId != null && entry.roleId === rule.roleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/folder/access/{id}:
|
* /rbac/folder/access/{id}:
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Stop a folder share from applying to hosts added later
|
* summary: Stop a folder share from applying to hosts added later
|
||||||
* description: Grants already fanned out to hosts stay; revoke those per host.
|
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
|
||||||
* tags:
|
* tags:
|
||||||
* - RBAC
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: fromHosts
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Removed. removedFromHosts counts the host grants removed.
|
||||||
|
* 404:
|
||||||
|
* description: No such rule.
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/folder/access/:id",
|
"/folder/access/:id",
|
||||||
@@ -741,11 +770,35 @@ router.delete(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const repository = createCurrentFolderAccessRepository();
|
const repository = createCurrentFolderAccessRepository();
|
||||||
if (!(await repository.findById(ruleId, req.userId!))) {
|
const rule = await repository.findById(ruleId, req.userId!);
|
||||||
|
if (!rule) {
|
||||||
return res.status(404).json({ error: "Rule not found" });
|
return res.status(404).json({ error: "Rule not found" });
|
||||||
}
|
}
|
||||||
await repository.deleteById(ruleId, req.userId!);
|
await repository.deleteById(ruleId, req.userId!);
|
||||||
res.json({ success: true });
|
|
||||||
|
let removedFromHosts = 0;
|
||||||
|
if (req.query.fromHosts === "true") {
|
||||||
|
const hosts =
|
||||||
|
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||||
|
req.userId!,
|
||||||
|
rule.folder,
|
||||||
|
);
|
||||||
|
const access = createCurrentRbacAccessRepository();
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (hostLock(host.id)) continue;
|
||||||
|
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
|
||||||
|
continue;
|
||||||
|
let changed = false;
|
||||||
|
const entries = await access.listHostAccess(host.id);
|
||||||
|
for (const entry of grantsForFolderRule(entries, rule)) {
|
||||||
|
await access.revokeHostAccess(entry.id, host.id);
|
||||||
|
removedFromHosts++;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) await pruneHostGrants(host.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ success: true, removedFromHosts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to delete folder access rule", error, {
|
databaseLogger.error("Failed to delete folder access rule", error, {
|
||||||
operation: "delete_folder_access",
|
operation: "delete_folder_access",
|
||||||
@@ -2174,4 +2227,157 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** A folder path as hosts store it ("Parent / Child"), or null. */
|
||||||
|
function folderParam(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const folder = value
|
||||||
|
.split(" / ")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
return folder && folder.length <= 255 ? folder : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/folder-auth/{protocol}:
|
||||||
|
* get:
|
||||||
|
* summary: Get the current user's personal login for shared hosts in a folder
|
||||||
|
* tags: [RBAC]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: protocol
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* - in: query
|
||||||
|
* name: folder
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The folder, protocol and credential id, or null when none is set.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid folder or protocol.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/folder-auth/:protocol",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const protocol = req.params.protocol;
|
||||||
|
const folder = folderParam(req.query.folder);
|
||||||
|
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||||
|
if (!isAuthOverrideProtocol(protocol)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid authentication protocol" });
|
||||||
|
}
|
||||||
|
const credentialId =
|
||||||
|
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
|
||||||
|
userId,
|
||||||
|
folder,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
res.json({ folder, protocol, credentialId });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get folder login", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch credential" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/folder-auth/{protocol}:
|
||||||
|
* put:
|
||||||
|
* summary: Set the current user's personal login for shared hosts in a folder
|
||||||
|
* description: >
|
||||||
|
* Used for every host shared with the user in this folder and its
|
||||||
|
* subfolders that has no personal login of its own. credentialId null
|
||||||
|
* clears it.
|
||||||
|
* tags: [RBAC]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: protocol
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [folder, credentialId]
|
||||||
|
* properties:
|
||||||
|
* folder: { type: string }
|
||||||
|
* credentialId: { type: integer, nullable: true }
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Saved.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid folder, protocol or credential id.
|
||||||
|
* 404:
|
||||||
|
* description: The credential is not one the user can use.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/folder-auth/:protocol",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const protocol = req.params.protocol;
|
||||||
|
const folder = folderParam(req.body?.folder);
|
||||||
|
const credentialId = req.body?.credentialId ?? null;
|
||||||
|
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||||
|
if (!isAuthOverrideProtocol(protocol)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid authentication protocol" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
credentialId !== null &&
|
||||||
|
(!Number.isInteger(credentialId) || credentialId <= 0)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "credentialId must be a positive integer or null",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = createCurrentSharedFolderAuthOverrideRepository();
|
||||||
|
if (credentialId === null) {
|
||||||
|
await repository.clearCredential(userId, folder, protocol);
|
||||||
|
} else {
|
||||||
|
if (!(await findUsableCredential(credentialId, userId))) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
await repository.setCredential(userId, folder, protocol, credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: await getAuditUsername(userId),
|
||||||
|
action:
|
||||||
|
credentialId === null
|
||||||
|
? "clear_shared_folder_auth_override"
|
||||||
|
: "set_shared_folder_auth_override",
|
||||||
|
resourceType: "folder",
|
||||||
|
resourceName: folder,
|
||||||
|
details: JSON.stringify({ protocol, credentialId }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
res.json({ success: true, folder, protocol, credentialId });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to set folder login", error);
|
||||||
|
res.status(500).json({ error: "Failed to update credential" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import type { RequestHandler, Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||||
|
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||||
|
|
||||||
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUserDeleteAccountRoutes(
|
||||||
|
router: Router,
|
||||||
|
authenticateJWT: RequestHandler,
|
||||||
|
): void {
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/delete-account:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete user account
|
||||||
|
* description: Deletes the authenticated user's account and all of their data.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Account deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Password is required.
|
||||||
|
* 401:
|
||||||
|
* description: Incorrect password.
|
||||||
|
* 403:
|
||||||
|
* description: Cannot delete external authentication accounts or the last admin user.
|
||||||
|
* 404:
|
||||||
|
* description: User not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete account.
|
||||||
|
*/
|
||||||
|
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
|
||||||
|
if (!isNonEmptyString(password)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password is required to delete account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const userRecord = await userRepository.findById(userId);
|
||||||
|
if (!userRecord) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isExternal) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error:
|
||||||
|
"Cannot delete external authentication accounts through this endpoint",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
authLogger.warn(
|
||||||
|
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||||
|
);
|
||||||
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Cannot delete the last admin user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same cleanup as an admin delete, so plugins hear user.deleted too.
|
||||||
|
await deleteUserAndRelatedData(userId);
|
||||||
|
|
||||||
|
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||||
|
res.json({ message: "Account deleted successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to delete user account", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete account" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
|||||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||||
|
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
|
||||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||||
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"Rejected proxy authentication from an untrusted source",
|
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||||
{
|
{
|
||||||
operation: "trusted_proxy_auth_rejected",
|
operation: "trusted_proxy_auth_rejected",
|
||||||
sourceAddress,
|
sourceAddress,
|
||||||
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||||
* @openapi
|
|
||||||
* /users/delete-account:
|
|
||||||
* delete:
|
|
||||||
* summary: Delete user account
|
|
||||||
* description: Deletes the authenticated user's account.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Account deleted successfully.
|
|
||||||
* 400:
|
|
||||||
* description: Password is required.
|
|
||||||
* 401:
|
|
||||||
* description: Incorrect password.
|
|
||||||
* 403:
|
|
||||||
* description: Cannot delete external authentication accounts or the last admin user.
|
|
||||||
* 404:
|
|
||||||
* description: User not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to delete account.
|
|
||||||
*/
|
|
||||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const { password } = req.body;
|
|
||||||
|
|
||||||
if (!isNonEmptyString(password)) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Password is required to delete account" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userRecord = await findCurrentUser(userId);
|
|
||||||
if (!userRecord) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isExternal) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error:
|
|
||||||
"Cannot delete external authentication accounts through this endpoint",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
|
||||||
if (!isMatch) {
|
|
||||||
authLogger.warn(
|
|
||||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
|
||||||
);
|
|
||||||
return res.status(401).json({ error: "Incorrect password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isAdmin) {
|
|
||||||
const adminCount = await createCurrentUserRepository().countAdmins();
|
|
||||||
if (adminCount <= 1) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Cannot delete the last admin user" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCurrentUserRepository().delete(userId);
|
|
||||||
|
|
||||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
|
||||||
res.json({ message: "Account deleted successfully" });
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to delete user account", err);
|
|
||||||
res.status(500).json({ error: "Failed to delete account" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerUserPasswordResetRoutes(router, { authManager });
|
registerUserPasswordResetRoutes(router, { authManager });
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
import type {
|
||||||
|
PluginSshTransportHost,
|
||||||
|
PluginSshTransportProvider,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a stream to the host if a provider claims it, else null. */
|
/** Opens a stream to the host if a provider claims it, else null. */
|
||||||
export async function openProvidedTransport(host: {
|
export async function openProvidedTransport(
|
||||||
id: number;
|
host: PluginSshTransportHost,
|
||||||
ip: string;
|
): Promise<Duplex | null> {
|
||||||
port: number;
|
|
||||||
}): Promise<Duplex | null> {
|
|
||||||
for (const { provider } of providers.values()) {
|
for (const { provider } of providers.values()) {
|
||||||
let claimed = false;
|
let claimed = false;
|
||||||
try {
|
try {
|
||||||
claimed = provider.claims(host.id);
|
claimed = provider.claims(host.id, host);
|
||||||
} catch {
|
} catch {
|
||||||
claimed = false;
|
claimed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export async function openSshTransport(
|
|||||||
id: host.id,
|
id: host.id,
|
||||||
ip: host.ip,
|
ip: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
|
userId: host.userId ?? null,
|
||||||
|
syncId: host.syncId ?? null,
|
||||||
|
connectionOrigin: host.connectionOrigin ?? null,
|
||||||
|
localOnly: Boolean(host.localOnly),
|
||||||
});
|
});
|
||||||
if (provided) {
|
if (provided) {
|
||||||
config.sock = provided;
|
config.sock = provided;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface SshConnectHost {
|
|||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
syncId?: string | null;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
localOnly?: boolean | null;
|
||||||
authType?: string | null;
|
authType?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
key?: string | Buffer | null;
|
key?: string | Buffer | null;
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ async function exportRow(
|
|||||||
: resolvedHost.tags || [],
|
: resolvedHost.tags || [],
|
||||||
pin: !!resolvedHost.pin,
|
pin: !!resolvedHost.pin,
|
||||||
notes: resolvedHost.notes || null,
|
notes: resolvedHost.notes || null,
|
||||||
|
color: resolvedHost.color || null,
|
||||||
|
icon: resolvedHost.icon || null,
|
||||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
|
|||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
} from "../../database/routes/host-normalizers.js";
|
} from "../../database/routes/host-normalizers.js";
|
||||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||||
@@ -571,6 +572,7 @@ export async function runBulkImport(
|
|||||||
? "true"
|
? "true"
|
||||||
: "false",
|
: "false",
|
||||||
notes: hostData.notes || null,
|
notes: hostData.notes || null,
|
||||||
|
...normalizeHostLookFields(hostData as Record<string, unknown>),
|
||||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||||
socks5Host: hostData.socks5Host || null,
|
socks5Host: hostData.socks5Host || null,
|
||||||
socks5Port: hostData.socks5Port || null,
|
socks5Port: hostData.socks5Port || null,
|
||||||
|
|||||||
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
|
|||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
...authOverrides,
|
...authOverrides,
|
||||||
ssh: {
|
ssh: resolution.folder
|
||||||
credentialId: resolution.credentialId,
|
? {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
},
|
folder: resolution.folder,
|
||||||
|
folderCredentialId: resolution.credentialId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
credentialId: resolution.credentialId,
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
authType:
|
authType:
|
||||||
credential.key || credential.privateKey
|
credential.key || credential.privateKey
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import net from "node:net";
|
|||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import { createWebSocketStream, type WebSocket } from "ws";
|
import { createWebSocketStream, type WebSocket } from "ws";
|
||||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||||
import { resolveHostById } from "./host-resolver.js";
|
import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
|
||||||
import { openSshTransport } from "./connect/transport.js";
|
import { openSshTransport } from "./connect/transport.js";
|
||||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
@@ -35,12 +35,13 @@ async function handleRelay(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ws = connection.socket as WebSocket;
|
const ws = connection.socket as WebSocket;
|
||||||
const url = String((connection.request as { url?: string }).url ?? "");
|
const url = String((connection.request as { url?: string }).url ?? "");
|
||||||
const hostId = Number(
|
const params = new URL(url, "http://relay").searchParams;
|
||||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
const hostId = Number(params.get("hostId"));
|
||||||
);
|
// A linked desktop knows its hosts by sync id, not by this server's id.
|
||||||
|
const syncId = params.get("syncId");
|
||||||
const userId = connection.userId;
|
const userId = connection.userId;
|
||||||
|
|
||||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
|
||||||
close(ws, 1008, "A host is required");
|
close(ws, 1008, "A host is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,9 @@ async function handleRelay(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = await resolveHostById(hostId, userId);
|
const host = syncId
|
||||||
|
? await resolveHostBySyncId(syncId, userId)
|
||||||
|
: await resolveHostById(hostId, userId);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
close(ws, 1008, "Host not found or access denied");
|
close(ws, 1008, "Host not found or access denied");
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +96,8 @@ async function handleRelay(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.warn("A relay could not reach its host", {
|
sshLogger.warn("A relay could not reach its host", {
|
||||||
operation: "ssh_relay_open",
|
operation: "ssh_relay_open",
|
||||||
hostId,
|
hostId: syncId ? undefined : hostId,
|
||||||
|
syncId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
finish();
|
finish();
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ export function isUserPluginFile(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a failed import from a plugin should try core's copy. */
|
||||||
|
export function shouldUseHostModule(
|
||||||
|
error: unknown,
|
||||||
|
specifier: string,
|
||||||
|
parentURL: string | undefined,
|
||||||
|
pluginsDir: string,
|
||||||
|
): boolean {
|
||||||
|
const code = (error as { code?: string })?.code;
|
||||||
|
// import() says ERR_MODULE_NOT_FOUND, a CommonJS require MODULE_NOT_FOUND.
|
||||||
|
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
||||||
|
return false;
|
||||||
|
return isBareSpecifier(specifier) && isUserPluginFile(parentURL, pluginsDir);
|
||||||
|
}
|
||||||
|
|
||||||
export function installHostModuleFallback(): void {
|
export function installHostModuleFallback(): void {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
@@ -49,9 +63,12 @@ export function installHostModuleFallback(): void {
|
|||||||
return nextResolve(specifier, context);
|
return nextResolve(specifier, context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
|
!shouldUseHostModule(
|
||||||
!isBareSpecifier(specifier) ||
|
error,
|
||||||
!isUserPluginFile(context.parentURL, getPluginsDir())
|
specifier,
|
||||||
|
context.parentURL,
|
||||||
|
getPluginsDir(),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import {
|
|||||||
pluginFeatures,
|
pluginFeatures,
|
||||||
type PluginManifest,
|
type PluginManifest,
|
||||||
} from "./manifest.js";
|
} from "./manifest.js";
|
||||||
import { requireSignedPlugins } from "./trust.js";
|
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
|
||||||
import {
|
import {
|
||||||
parseChangelog,
|
parseChangelog,
|
||||||
parseReleaseNotes,
|
parseReleaseNotes,
|
||||||
@@ -1048,7 +1048,15 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
|||||||
await readUninstalled(),
|
await readUninstalled(),
|
||||||
);
|
);
|
||||||
const restored: LoadedPlugin[] = [];
|
const restored: LoadedPlugin[] = [];
|
||||||
for (const id of ids) {
|
// Shown on the Plugins tab, so a plugin that could not be fetched is not
|
||||||
|
// listed as working. It stays on and is tried again on the next start.
|
||||||
|
const markMissing = (id: string, message: string) =>
|
||||||
|
recordState(
|
||||||
|
id,
|
||||||
|
"failed",
|
||||||
|
`Not in this image and could not be downloaded: ${message}`,
|
||||||
|
).catch(() => {});
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
try {
|
try {
|
||||||
const record = await repository.findById(id);
|
const record = await repository.findById(id);
|
||||||
const { source, release } = await requireRelease(
|
const { source, release } = await requireRelease(
|
||||||
@@ -1075,11 +1083,14 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
|||||||
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
|
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
|
||||||
operation: "plugin_restore_missing",
|
operation: "plugin_restore_missing",
|
||||||
});
|
});
|
||||||
|
await markMissing(id, message);
|
||||||
// The registry is down or offline, so the rest would fail the same way.
|
// The registry is down or offline, so the rest would fail the same way.
|
||||||
if (
|
if (
|
||||||
error instanceof PluginManageError &&
|
error instanceof PluginManageError &&
|
||||||
error.code === "REGISTRY_UNREACHABLE"
|
error.code === "REGISTRY_UNREACHABLE"
|
||||||
) {
|
) {
|
||||||
|
for (const rest of ids.slice(index + 1))
|
||||||
|
await markMissing(rest, message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1514,6 +1525,8 @@ interface StagedUpload {
|
|||||||
file: string;
|
file: string;
|
||||||
manifest: PluginManifest;
|
manifest: PluginManifest;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** Set when the upload came with a .sig an official key made. */
|
||||||
|
official: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploads = new Map<string, StagedUpload>();
|
const uploads = new Map<string, StagedUpload>();
|
||||||
@@ -1537,6 +1550,29 @@ export interface UploadPreview {
|
|||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
/** The version of an earlier upload this one replaces. */
|
/** The version of an earlier upload this one replaces. */
|
||||||
replaces: string | null;
|
replaces: string | null;
|
||||||
|
/** The .sig sent with it was made by the official Termix key. */
|
||||||
|
official: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a .sig sent with an upload. An official signature makes the file
|
||||||
|
* the same as a registry download, so it may stand in for one that could not
|
||||||
|
* be fetched (an offline install). Anything else is refused.
|
||||||
|
*/
|
||||||
|
function checkUploadSignature(
|
||||||
|
buffer: Buffer,
|
||||||
|
signature: string,
|
||||||
|
): { official: boolean } {
|
||||||
|
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
const result = verifyAnyTrusted(buffer, sha256, signature);
|
||||||
|
if (result.ok === false) {
|
||||||
|
throw new PluginManageError(
|
||||||
|
`The signature does not match this file: ${result.reason}`,
|
||||||
|
400,
|
||||||
|
"INVALID_SIGNATURE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { official: result.tier === "official" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertUploadsAllowed(): Promise<void> {
|
async function assertUploadsAllowed(): Promise<void> {
|
||||||
@@ -1593,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
|
|||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only another upload of the same id can be replaced from a file. */
|
/**
|
||||||
async function assertReplaceable(pluginId: string): Promise<string | null> {
|
* Only another upload of the same id can be replaced from a file. An
|
||||||
|
* officially signed file may also fill in a plugin whose code is missing, like
|
||||||
|
* one that was on but could not be downloaded.
|
||||||
|
*/
|
||||||
|
async function assertReplaceable(
|
||||||
|
pluginId: string,
|
||||||
|
official = false,
|
||||||
|
): Promise<string | null> {
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
|
if (official && !loader.get(pluginId)) return null;
|
||||||
if (loader.bundledIds().has(pluginId)) {
|
if (loader.bundledIds().has(pluginId)) {
|
||||||
throw new PluginManageError(
|
throw new PluginManageError(
|
||||||
`${pluginId} ships with Termix and can only be replaced by a signed release`,
|
`${pluginId} ships with Termix and can only be replaced by a signed release`,
|
||||||
@@ -1620,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
|
|||||||
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
|
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
|
||||||
* it asks for. Nothing is loaded yet.
|
* it asks for. Nothing is loaded yet.
|
||||||
*/
|
*/
|
||||||
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
export async function stageUpload(
|
||||||
|
buffer: Buffer,
|
||||||
|
signature: string | null = null,
|
||||||
|
): Promise<UploadPreview> {
|
||||||
await assertUploadsAllowed();
|
await assertUploadsAllowed();
|
||||||
await clearUploads(Date.now());
|
await clearUploads(Date.now());
|
||||||
const manifest = validateUpload(buffer);
|
const manifest = validateUpload(buffer);
|
||||||
const replaces = await assertReplaceable(manifest.id);
|
const { official } = signature
|
||||||
|
? checkUploadSignature(buffer, signature)
|
||||||
|
: { official: false };
|
||||||
|
const replaces = await assertReplaceable(manifest.id, official);
|
||||||
|
|
||||||
const token = crypto.randomBytes(16).toString("hex");
|
const token = crypto.randomBytes(16).toString("hex");
|
||||||
const dir = getDownloadsDir();
|
const dir = getDownloadsDir();
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
const file = path.join(dir, `upload-${token}.tmxplug`);
|
const file = path.join(dir, `upload-${token}.tmxplug`);
|
||||||
await fs.promises.writeFile(file, buffer);
|
await fs.promises.writeFile(file, buffer);
|
||||||
|
// Next to the file, where the loader looks for it.
|
||||||
|
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
|
||||||
uploads.set(token, {
|
uploads.set(token, {
|
||||||
file,
|
file,
|
||||||
manifest,
|
manifest,
|
||||||
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
expiresAt: Date.now() + UPLOAD_TTL_MS,
|
||||||
|
official,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1647,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
|
|||||||
features: pluginFeatures(manifest.features),
|
features: pluginFeatures(manifest.features),
|
||||||
capabilities: [...manifest.capabilities],
|
capabilities: [...manifest.capabilities],
|
||||||
replaces,
|
replaces,
|
||||||
|
official,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1676,7 +1730,7 @@ export function installUpload(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assertConsent(options.capabilities, manifest.capabilities);
|
assertConsent(options.capabilities, manifest.capabilities);
|
||||||
await assertReplaceable(pluginId);
|
await assertReplaceable(pluginId, upload.official);
|
||||||
|
|
||||||
const { createCurrentPluginRepository } = await repos();
|
const { createCurrentPluginRepository } = await repos();
|
||||||
const repository = createCurrentPluginRepository();
|
const repository = createCurrentPluginRepository();
|
||||||
@@ -1689,6 +1743,7 @@ export function installUpload(
|
|||||||
plugin = await swapIn(pluginId, { upload: upload.file });
|
plugin = await swapIn(pluginId, { upload: upload.file });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fs.promises.rm(upload.file, { force: true });
|
await fs.promises.rm(upload.file, { force: true });
|
||||||
|
await fs.promises.rm(`${upload.file}.sig`, { force: true });
|
||||||
const { loader } = getPluginRuntime();
|
const { loader } = getPluginRuntime();
|
||||||
if (before && wasEnabled && loader.get(pluginId)) {
|
if (before && wasEnabled && loader.get(pluginId)) {
|
||||||
await startAndRecord(pluginId);
|
await startAndRecord(pluginId);
|
||||||
@@ -1701,7 +1756,7 @@ export function installUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await repository.update(pluginId, {
|
await repository.update(pluginId, {
|
||||||
tier: UNVERIFIED_TIER,
|
tier: upload.official ? "official" : UNVERIFIED_TIER,
|
||||||
registryId: null,
|
registryId: null,
|
||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
@@ -1725,7 +1780,7 @@ export function installUpload(
|
|||||||
}
|
}
|
||||||
const state = await startAndRecord(pluginId);
|
const state = await startAndRecord(pluginId);
|
||||||
pluginLogger.warn(
|
pluginLogger.warn(
|
||||||
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
|
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
|
||||||
{ operation: "plugin_install" },
|
{ operation: "plugin_install" },
|
||||||
);
|
);
|
||||||
return { id: pluginId, version: manifest.version, state };
|
return { id: pluginId, version: manifest.version, state };
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
SyncRow,
|
SyncRow,
|
||||||
} from "@termix-ssh/plugin-sdk/backend";
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
|
||||||
/** Core-only extras a plugin registration cannot set. */
|
/** Extras core sets for its own entities. A plugin may set the first four. */
|
||||||
export interface CoreSyncEntityOptions {
|
export interface CoreSyncEntityOptions {
|
||||||
/** Server-to-desktop only. A desktop never pushes it. */
|
/** Server-to-desktop only. A desktop never pushes it. */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
|
|||||||
encryptedFields: readonly string[];
|
encryptedFields: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a plugin registration may carry. Anything else is dropped. */
|
||||||
|
const PLUGIN_KEYS = [
|
||||||
|
"type",
|
||||||
|
"table",
|
||||||
|
"userColumn",
|
||||||
|
"encryptedFields",
|
||||||
|
"references",
|
||||||
|
"order",
|
||||||
|
"readOnlyFields",
|
||||||
|
"singleton",
|
||||||
|
"answersTo",
|
||||||
|
"shouldSync",
|
||||||
|
"permissions",
|
||||||
|
"serialize",
|
||||||
|
"deserialize",
|
||||||
|
"afterWrite",
|
||||||
|
"remove",
|
||||||
|
"readOnly",
|
||||||
|
"load",
|
||||||
|
"write",
|
||||||
|
"erase",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
|
||||||
|
const picked: Record<string, unknown> = {};
|
||||||
|
for (const key of PLUGIN_KEYS) {
|
||||||
|
if (entity[key] !== undefined) picked[key] = entity[key];
|
||||||
|
}
|
||||||
|
const out = picked as unknown as SyncEntityRegistration;
|
||||||
|
if (out.load || out.write || out.erase) {
|
||||||
|
if (!out.load || !out.write || !out.erase) {
|
||||||
|
throw new Error(
|
||||||
|
`Sync entity "${entity.type}" needs load, write and erase together`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A plugin only ever syncs its own rows both ways; rows it builds for
|
||||||
|
// a user come down as read only copies.
|
||||||
|
if (out.readOnly !== true) {
|
||||||
|
throw new Error(
|
||||||
|
`Sync entity "${entity.type}" with load must be readOnly`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!out.table) {
|
||||||
|
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
const entities = new Map<string, RegisteredSyncEntity>();
|
const entities = new Map<string, RegisteredSyncEntity>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -81,7 +129,7 @@ export function registerEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registered: RegisteredSyncEntity = {
|
const registered: RegisteredSyncEntity = {
|
||||||
...entity,
|
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
|
||||||
...(owner === CORE_OWNER ? core : {}),
|
...(owner === CORE_OWNER ? core : {}),
|
||||||
owner,
|
owner,
|
||||||
order: entity.order ?? 100,
|
order: entity.order ?? 100,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
|||||||
import {
|
import {
|
||||||
createResolvers,
|
createResolvers,
|
||||||
deleteStoredRow,
|
deleteStoredRow,
|
||||||
|
findStoredIds,
|
||||||
listStoredRows,
|
listStoredRows,
|
||||||
loadWireRow,
|
loadWireRow,
|
||||||
loadWireRows,
|
loadWireRows,
|
||||||
@@ -271,6 +272,11 @@ export function createSyncHost(): PluginSyncHost {
|
|||||||
ensureCore: () => ensureCoreEntities(),
|
ensureCore: () => ensureCoreEntities(),
|
||||||
onChanged: (listener) => onEntitiesChanged(listener),
|
onChanged: (listener) => onEntitiesChanged(listener),
|
||||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||||
|
idsOf: async (type, userId, syncIds) => {
|
||||||
|
const entity = getEntity(type);
|
||||||
|
if (!entity || entity.load) return {};
|
||||||
|
return findStoredIds(entity, userId, syncIds);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
replica: {
|
replica: {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ async function reconcileListeners(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyListeners(userId: string): void {
|
export function notifyListeners(userId: string): void {
|
||||||
const set = listeners.get(userId);
|
const set = listeners.get(userId);
|
||||||
if (!set) return;
|
if (!set) return;
|
||||||
for (const res of set) {
|
for (const res of set) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "../store.js";
|
} from "../store.js";
|
||||||
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
|
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
|
||||||
import { orderSelfReferences } from "../wire.js";
|
import { orderSelfReferences } from "../wire.js";
|
||||||
import { reconcileUser } from "./feed.js";
|
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
|
||||||
|
|
||||||
export interface PushOp {
|
export interface PushOp {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -278,6 +278,12 @@ export async function applyPush(
|
|||||||
|
|
||||||
// Refresh the snapshot so the next pull serves what was just written.
|
// Refresh the snapshot so the next pull serves what was just written.
|
||||||
await reconcileUser(userId, { force: true });
|
await reconcileUser(userId, { force: true });
|
||||||
|
// The reconcile finds nothing new, since the push wrote the records itself,
|
||||||
|
// so tell this user's other clients, and other users who may share a row.
|
||||||
|
if ([...results.values()].some((result) => result.status === "ok")) {
|
||||||
|
notifyListeners(userId);
|
||||||
|
markChanged();
|
||||||
|
}
|
||||||
return ops.map(
|
return ops.map(
|
||||||
(op) =>
|
(op) =>
|
||||||
results.get(recordKey(op.entityType, op.syncId)) ?? {
|
results.get(recordKey(op.entityType, op.syncId)) ?? {
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ export async function findStoredRow(
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */
|
||||||
|
export async function findStoredIds(
|
||||||
|
entity: RegisteredSyncEntity,
|
||||||
|
userId: string,
|
||||||
|
syncIds: string[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const ids: Record<string, number> = {};
|
||||||
|
if (entity.singleton || !entity.table) return ids;
|
||||||
|
for (const syncId of new Set(syncIds)) {
|
||||||
|
const row = await findStoredRow(entity, userId, syncId);
|
||||||
|
if (row && typeof row.id === "number") ids[syncId] = row.id;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** Gives an old row with no syncId one, so it can take part. */
|
/** Gives an old row with no syncId one, so it can take part. */
|
||||||
async function ensureSyncId(
|
async function ensureSyncId(
|
||||||
entity: RegisteredSyncEntity,
|
entity: RegisteredSyncEntity,
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
folderAndParents,
|
||||||
|
SharedFolderAuthOverrideRepository,
|
||||||
|
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
|
||||||
|
describe("folderAndParents", () => {
|
||||||
|
it("lists the folder, then each folder above it", () => {
|
||||||
|
expect(folderAndParents("Prod / Web / EU")).toEqual([
|
||||||
|
"Prod / Web / EU",
|
||||||
|
"Prod / Web",
|
||||||
|
"Prod",
|
||||||
|
]);
|
||||||
|
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
|
||||||
|
expect(folderAndParents("")).toEqual([]);
|
||||||
|
expect(folderAndParents(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SharedFolderAuthOverrideRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await adapter?.close();
|
||||||
|
adapter = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(onWrite?: () => void) {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
await adapter.exec(`
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
|
||||||
|
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
|
||||||
|
VALUES (7, 'recipient', 'cred-seven', 'password'),
|
||||||
|
(8, 'recipient', 'cred-eight', 'password');
|
||||||
|
`);
|
||||||
|
return new SharedFolderAuthOverrideRepository(context, onWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sets, updates and clears a login per user, folder and protocol", async () => {
|
||||||
|
let writes = 0;
|
||||||
|
const repository = await createRepository(() => {
|
||||||
|
writes += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 8);
|
||||||
|
await repository.setCredential("recipient", "Prod", "rdp", 7);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(8);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("other", "Prod", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
await expect(
|
||||||
|
repository.clearCredential("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(writes).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the login of the closest folder above a host", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
|
||||||
|
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod / DB", "ssh"),
|
||||||
|
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Staging", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", "Prod", "rdp"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
await expect(
|
||||||
|
repository.findClosest("recipient", null, "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the login when its credential is deleted", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
await repository.setCredential("recipient", "Prod", "ssh", 7);
|
||||||
|
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
|
||||||
|
await expect(
|
||||||
|
repository.findCredentialId("recipient", "Prod", "ssh"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -214,3 +214,43 @@ describe("host.created", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("host color and icon", () => {
|
||||||
|
async function stored(id: number) {
|
||||||
|
const [row] = await ctx.drizzle
|
||||||
|
.select({ color: hosts.color, icon: hosts.icon })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.id, id));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves them on create and keeps them when an update leaves them out", async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post("/host/db/host")
|
||||||
|
.send({
|
||||||
|
...base,
|
||||||
|
authType: "password",
|
||||||
|
password: "pw",
|
||||||
|
color: "#22C55E",
|
||||||
|
icon: "database",
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
expect(await stored(created.body.id)).toEqual({
|
||||||
|
color: "#22c55e",
|
||||||
|
icon: "database",
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put(`/host/db/host/${created.body.id}`)
|
||||||
|
.send({ ...base, name: "renamed" });
|
||||||
|
expect(await stored(created.body.id)).toEqual({
|
||||||
|
color: "#22c55e",
|
||||||
|
icon: "database",
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put(`/host/db/host/${created.body.id}`)
|
||||||
|
.send({ ...base, color: null, icon: null });
|
||||||
|
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
keepsStoredSshAuth,
|
keepsStoredSshAuth,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
renameFolderPath,
|
renameFolderPath,
|
||||||
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
|
|||||||
expect(impliedSshAuthType({})).toBeUndefined();
|
expect(impliedSshAuthType({})).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeHostLookFields", () => {
|
||||||
|
it("leaves both fields out when the write does not send them", () => {
|
||||||
|
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a hex color and a Lucide name", () => {
|
||||||
|
expect(
|
||||||
|
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
|
||||||
|
).toEqual({ color: "#3b82f6", icon: "server-cog" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null for cleared or bad values", () => {
|
||||||
|
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
|
||||||
|
{ color: null, icon: null },
|
||||||
|
);
|
||||||
|
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
|
||||||
|
color: null,
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -488,6 +488,35 @@ describe("plugins route", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists a plugin's engine ranges to any user", async () => {
|
||||||
|
state.plugins.set(
|
||||||
|
"sample-plugin",
|
||||||
|
makePlugin({
|
||||||
|
manifestJson: JSON.stringify({
|
||||||
|
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
state.plugins.set(
|
||||||
|
"bad-engine",
|
||||||
|
makePlugin({
|
||||||
|
id: "bad-engine",
|
||||||
|
manifestJson: JSON.stringify({ engine: { api: 2 } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/plugins`, {
|
||||||
|
headers: { "x-test-user-id": "regular-user" },
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
|
||||||
|
|
||||||
|
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
|
||||||
|
api: "^1.13",
|
||||||
|
termix: ">=26.10.6",
|
||||||
|
});
|
||||||
|
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it("reports an enabled plugin that safe mode keeps off", async () => {
|
it("reports an enabled plugin that safe mode keeps off", async () => {
|
||||||
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
|
||||||
|
|
||||||
@@ -643,6 +672,7 @@ describe("plugins route", () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
state: "enabled",
|
state: "enabled",
|
||||||
runsOnServer: false,
|
runsOnServer: false,
|
||||||
|
engine: {},
|
||||||
contributes: null,
|
contributes: null,
|
||||||
dependencies: {},
|
dependencies: {},
|
||||||
optionalDependencies: {},
|
optionalDependencies: {},
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ const state = vi.hoisted(() => ({
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isAdminBypass: false,
|
isAdminBypass: false,
|
||||||
},
|
},
|
||||||
credentialOwned: true,
|
credentialUsable: true,
|
||||||
credentialId: 7 as number | null,
|
credentialId: 7 as number | null,
|
||||||
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
|
||||||
|
folderWrites: [] as Array<{
|
||||||
|
folder: string;
|
||||||
|
protocol: string;
|
||||||
|
credentialId: number | null;
|
||||||
|
}>,
|
||||||
auditCalls: [] as Array<Record<string, unknown>>,
|
auditCalls: [] as Array<Record<string, unknown>>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -70,11 +75,12 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async (credentialId: number) =>
|
||||||
|
state.credentialUsable ? { id: credentialId } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
createCurrentCredentialRepository: () => ({
|
|
||||||
findByIdForUser: async () =>
|
|
||||||
state.credentialOwned ? { id: state.credentialId } : null,
|
|
||||||
}),
|
|
||||||
createCurrentSharedHostAuthOverrideRepository: () => ({
|
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||||
findCredentialId: async () => state.credentialId,
|
findCredentialId: async () => state.credentialId,
|
||||||
setCredential: async (
|
setCredential: async (
|
||||||
@@ -96,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||||
|
findCredentialId: async () => 7,
|
||||||
|
setCredential: async (
|
||||||
|
_userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: string,
|
||||||
|
credentialId: number,
|
||||||
|
) => {
|
||||||
|
state.folderWrites.push({ folder, protocol, credentialId });
|
||||||
|
},
|
||||||
|
clearCredential: async (
|
||||||
|
_userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: string,
|
||||||
|
) => {
|
||||||
|
state.folderWrites.push({ folder, protocol, credentialId: null });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
createCurrentUserRepository: () => ({
|
createCurrentUserRepository: () => ({
|
||||||
findById: async () => ({ id: "recipient", username: "recipient" }),
|
findById: async () => ({ id: "recipient", username: "recipient" }),
|
||||||
}),
|
}),
|
||||||
@@ -108,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||||
|
getAuditUsername: async (userId: string) => userId,
|
||||||
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
|
||||||
state.auditCalls.push(entry);
|
state.auditCalls.push(entry);
|
||||||
}),
|
}),
|
||||||
@@ -133,6 +159,8 @@ describe("shared host authentication override routes", () => {
|
|||||||
method: "get" | "put",
|
method: "get" | "put",
|
||||||
body: Record<string, unknown> = {},
|
body: Record<string, unknown> = {},
|
||||||
protocol = "ssh",
|
protocol = "ssh",
|
||||||
|
path = "/host-access/:hostId/auth/:protocol",
|
||||||
|
query: Record<string, unknown> = {},
|
||||||
): Promise<{ status: number; body: unknown }> {
|
): Promise<{ status: number; body: unknown }> {
|
||||||
const routeLayer = (
|
const routeLayer = (
|
||||||
router as unknown as {
|
router as unknown as {
|
||||||
@@ -151,15 +179,14 @@ describe("shared host authentication override routes", () => {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
).stack.find(
|
).stack.find(
|
||||||
(layer) =>
|
(layer) => layer.route?.path === path && layer.route.methods[method],
|
||||||
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
|
|
||||||
layer.route.methods[method],
|
|
||||||
);
|
);
|
||||||
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
|
||||||
|
|
||||||
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
|
||||||
const req = {
|
const req = {
|
||||||
params: { hostId: "42", protocol },
|
params: { hostId: "42", protocol },
|
||||||
|
query,
|
||||||
body,
|
body,
|
||||||
headers: {},
|
headers: {},
|
||||||
ip: "127.0.0.1",
|
ip: "127.0.0.1",
|
||||||
@@ -206,12 +233,106 @@ describe("shared host authentication override routes", () => {
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isAdminBypass: false,
|
isAdminBypass: false,
|
||||||
};
|
};
|
||||||
state.credentialOwned = true;
|
state.credentialUsable = true;
|
||||||
state.credentialId = 7;
|
state.credentialId = 7;
|
||||||
state.writes = [];
|
state.writes = [];
|
||||||
|
state.folderWrites = [];
|
||||||
state.auditCalls = [];
|
state.auditCalls = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("folder logins", () => {
|
||||||
|
const FOLDER = "/folder-auth/:protocol";
|
||||||
|
|
||||||
|
it("reads the login for a folder", async () => {
|
||||||
|
const response = await invoke("get", {}, "ssh", FOLDER, {
|
||||||
|
folder: " Prod / Web ",
|
||||||
|
});
|
||||||
|
expect(response).toEqual({
|
||||||
|
status: 200,
|
||||||
|
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets and clears the login and audits both", async () => {
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 8 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: null },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(state.folderWrites).toEqual([
|
||||||
|
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
|
||||||
|
{ folder: "Prod", protocol: "ssh", credentialId: null },
|
||||||
|
]);
|
||||||
|
expect(state.auditCalls.map((call) => call.action)).toEqual([
|
||||||
|
"set_shared_folder_auth_override",
|
||||||
|
"clear_shared_folder_auth_override",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a bad folder, protocol or credential", async () => {
|
||||||
|
expect(
|
||||||
|
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "x".repeat(300), credentialId: 8 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 8 },
|
||||||
|
"smtp",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: -1 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
state.credentialUsable = false;
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await invoke(
|
||||||
|
"put",
|
||||||
|
{ folder: "Prod", credentialId: 99 },
|
||||||
|
"ssh",
|
||||||
|
FOLDER,
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(state.folderWrites).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns the current override for a role-derived shared recipient", async () => {
|
it("returns the current override for a role-derived shared recipient", async () => {
|
||||||
const response = await invoke("get");
|
const response = await invoke("get");
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
@@ -254,7 +375,7 @@ describe("shared host authentication override routes", () => {
|
|||||||
const invalidResponse = await invoke("put", { credentialId: 0 });
|
const invalidResponse = await invoke("put", { credentialId: 0 });
|
||||||
expect(invalidResponse.status).toBe(400);
|
expect(invalidResponse.status).toBe(400);
|
||||||
|
|
||||||
state.credentialOwned = false;
|
state.credentialUsable = false;
|
||||||
const foreignResponse = await invoke("put", { credentialId: 99 });
|
const foreignResponse = await invoke("put", { credentialId: 99 });
|
||||||
expect(foreignResponse.status).toBe(404);
|
expect(foreignResponse.status).toBe(404);
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { pruneHostGrants, pruneCredentialGrants } =
|
const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
|
||||||
await import("../../../database/routes/rbac.js");
|
await import("../../../database/routes/rbac.js");
|
||||||
|
|
||||||
const chain = (): Grant[] => [
|
const chain = (): Grant[] => [
|
||||||
@@ -105,3 +105,27 @@ describe("share pruning", () => {
|
|||||||
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
|
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("grantsForFolderRule", () => {
|
||||||
|
const entries = [
|
||||||
|
{ id: 1, userId: "bob", roleId: null },
|
||||||
|
{ id: 2, userId: null, roleId: 7 },
|
||||||
|
{ id: 3, userId: "carol", roleId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("picks the grants of the rule's user or role", () => {
|
||||||
|
expect(
|
||||||
|
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
|
||||||
|
(e) => e.id,
|
||||||
|
),
|
||||||
|
).toEqual([1]);
|
||||||
|
expect(
|
||||||
|
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
|
||||||
|
(e) => e.id,
|
||||||
|
),
|
||||||
|
).toEqual([2]);
|
||||||
|
expect(
|
||||||
|
grantsForFolderRule(entries, { userId: null, roleId: null }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import type { Request, RequestHandler, Response } from "express";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
users: new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isExternal: boolean;
|
||||||
|
passwordHash: string;
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
adminCount: 1,
|
||||||
|
deleted: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
authLogger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("bcryptjs", () => ({
|
||||||
|
default: {
|
||||||
|
compare: async (password: string, hash: string) =>
|
||||||
|
hash === `hash:${password}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentUserRepository: () => ({
|
||||||
|
findById: async (id: string) => state.users.get(id) ?? null,
|
||||||
|
countAdmins: async () => state.adminCount,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||||
|
deleteUserAndRelatedData: async (userId: string) => {
|
||||||
|
state.deleted.push(userId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { registerUserDeleteAccountRoutes } =
|
||||||
|
await import("../../../database/routes/user-delete-account-routes.js");
|
||||||
|
|
||||||
|
let handler: RequestHandler | null = null;
|
||||||
|
registerUserDeleteAccountRoutes(
|
||||||
|
{
|
||||||
|
delete: (_path: string, ...handlers: RequestHandler[]) => {
|
||||||
|
handler = handlers[handlers.length - 1];
|
||||||
|
},
|
||||||
|
} as unknown as import("express").Router,
|
||||||
|
(_req, _res, next) => next(),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function deleteAccount(userId: string, body: Record<string, unknown>) {
|
||||||
|
const req = { userId, body } as unknown as Request;
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
jsonBody: null as Record<string, unknown> | null,
|
||||||
|
status(code: number) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload: Record<string, unknown>) {
|
||||||
|
this.jsonBody = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await handler!(req, res as unknown as Response, () => {});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.adminCount = 1;
|
||||||
|
state.deleted = [];
|
||||||
|
state.users = new Map([
|
||||||
|
[
|
||||||
|
"u1",
|
||||||
|
{
|
||||||
|
id: "u1",
|
||||||
|
username: "user",
|
||||||
|
isAdmin: false,
|
||||||
|
isExternal: false,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"admin1",
|
||||||
|
{
|
||||||
|
id: "admin1",
|
||||||
|
username: "admin",
|
||||||
|
isAdmin: true,
|
||||||
|
isExternal: false,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"sso1",
|
||||||
|
{
|
||||||
|
id: "sso1",
|
||||||
|
username: "sso",
|
||||||
|
isAdmin: false,
|
||||||
|
isExternal: true,
|
||||||
|
passwordHash: "hash:secret",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /delete-account", () => {
|
||||||
|
it("runs the full user cleanup", async () => {
|
||||||
|
const res = await deleteAccount("u1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(state.deleted).toEqual(["u1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs a password", async () => {
|
||||||
|
const res = await deleteAccount("u1", {});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a wrong password", async () => {
|
||||||
|
const res = await deleteAccount("u1", { password: "nope" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses external accounts", async () => {
|
||||||
|
const res = await deleteAccount("sso1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the last admin", async () => {
|
||||||
|
const res = await deleteAccount("admin1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(state.deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin go when another admin is left", async () => {
|
||||||
|
state.adminCount = 2;
|
||||||
|
const res = await deleteAccount("admin1", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(state.deleted).toEqual(["admin1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s for an unknown user", async () => {
|
||||||
|
const res = await deleteAccount("ghost", { password: "secret" });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,29 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ssh transport providers", () => {
|
describe("ssh transport providers", () => {
|
||||||
|
it("tells providers the owner, sync id and origin of the host", async () => {
|
||||||
|
const claims = vi.fn(() => false);
|
||||||
|
provideSshTransport("a", { claims, open: vi.fn() });
|
||||||
|
await openSshTransport(
|
||||||
|
{
|
||||||
|
...host,
|
||||||
|
userId: "u1",
|
||||||
|
syncId: "s1",
|
||||||
|
connectionOrigin: "remote",
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
).catch(() => {});
|
||||||
|
expect(claims).toHaveBeenCalledWith(7, {
|
||||||
|
id: 7,
|
||||||
|
ip: "10.0.0.7",
|
||||||
|
port: 22,
|
||||||
|
userId: "u1",
|
||||||
|
syncId: "s1",
|
||||||
|
connectionOrigin: "remote",
|
||||||
|
localOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("opens nothing when no provider claims the host", async () => {
|
it("opens nothing when no provider claims the host", async () => {
|
||||||
provideSshTransport("a", { claims: () => false, open: vi.fn() });
|
provideSshTransport("a", { claims: () => false, open: vi.fn() });
|
||||||
expect(await openProvidedTransport(host)).toBeNull();
|
expect(await openProvidedTransport(host)).toBeNull();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
|
|||||||
isAdminBypass: false,
|
isAdminBypass: false,
|
||||||
overrideCredentialId: null as number | null,
|
overrideCredentialId: null as number | null,
|
||||||
credentials: new Map<string, Record<string, unknown>>(),
|
credentials: new Map<string, Record<string, unknown>>(),
|
||||||
|
sharedCredentials: new Map<string, Record<string, unknown>>(),
|
||||||
auditCalls: [] as Record<string, unknown>[],
|
auditCalls: [] as Record<string, unknown>[],
|
||||||
sharedSecret: null as Record<string, unknown> | null,
|
sharedSecret: null as Record<string, unknown> | null,
|
||||||
activeHostAccessId: null as number | null,
|
activeHostAccessId: null as number | null,
|
||||||
@@ -37,6 +38,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async (credentialId: number, userId: string) =>
|
||||||
|
state.credentials.get(`${credentialId}:${userId}`) ??
|
||||||
|
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
|
||||||
|
null,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../utils/audit-logger.js", () => ({
|
vi.mock("../../utils/audit-logger.js", () => ({
|
||||||
logAudit: async (params: Record<string, unknown>) => {
|
logAudit: async (params: Record<string, unknown>) => {
|
||||||
state.auditCalls.push(params);
|
state.auditCalls.push(params);
|
||||||
@@ -125,6 +133,7 @@ beforeEach(() => {
|
|||||||
state.isAdminBypass = false;
|
state.isAdminBypass = false;
|
||||||
state.overrideCredentialId = null;
|
state.overrideCredentialId = null;
|
||||||
state.credentials.clear();
|
state.credentials.clear();
|
||||||
|
state.sharedCredentials.clear();
|
||||||
state.auditCalls = [];
|
state.auditCalls = [];
|
||||||
state.sharedSecret = null;
|
state.sharedSecret = null;
|
||||||
state.activeHostAccessId = null;
|
state.activeHostAccessId = null;
|
||||||
@@ -376,6 +385,61 @@ describe("resolveHostById", () => {
|
|||||||
expect(host.username).toBe("shared-login");
|
expect(host.username).toBe("shared-login");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a credential shared with the recipient as their personal override", async () => {
|
||||||
|
state.overrideCredentialId = 7;
|
||||||
|
state.sharedCredentials.set("7:recipient", {
|
||||||
|
id: 7,
|
||||||
|
username: "shared-cred-user",
|
||||||
|
password: "shared-cred-pass",
|
||||||
|
privateKey: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.username).toBe("shared-cred-user");
|
||||||
|
expect(host.password).toBe("shared-cred-pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the host's credential when it is also shared with the recipient", async () => {
|
||||||
|
state.host = baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
password: null,
|
||||||
|
credentialId: 1,
|
||||||
|
});
|
||||||
|
state.sharedCredentials.set("1:recipient", {
|
||||||
|
id: 1,
|
||||||
|
username: "deploy",
|
||||||
|
password: null,
|
||||||
|
privateKey: "OWNER-KEY-COPY",
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: "ssh-ed25519",
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = (await resolveHostById(42, "recipient")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(host.authType).toBe("key");
|
||||||
|
expect(host.key).toBe("OWNER-KEY-COPY");
|
||||||
|
expect(host.username).toBe("deploy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a recipient when the host's credential is not shared with them", async () => {
|
||||||
|
state.host = baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
password: null,
|
||||||
|
credentialId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
|
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
|
||||||
expect(await resolveHostById(42, "recipient")).toBeNull();
|
expect(await resolveHostById(42, "recipient")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
resolveHostById: vi.fn(),
|
resolveHostById: vi.fn(),
|
||||||
|
resolveHostBySyncId: vi.fn(),
|
||||||
openSshTransport: vi.fn(),
|
openSshTransport: vi.fn(),
|
||||||
handler: null as null | ((connection: unknown) => Promise<void>),
|
handler: null as null | ((connection: unknown) => Promise<void>),
|
||||||
}));
|
}));
|
||||||
@@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => {
|
|||||||
});
|
});
|
||||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||||
resolveHostById: mocks.resolveHostById,
|
resolveHostById: mocks.resolveHostById,
|
||||||
|
resolveHostBySyncId: mocks.resolveHostBySyncId,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../hosts/connect/transport.js", () => ({
|
vi.mock("../../hosts/connect/transport.js", () => ({
|
||||||
openSshTransport: mocks.openSshTransport,
|
openSshTransport: mocks.openSshTransport,
|
||||||
@@ -114,6 +116,23 @@ describe("ssh relay", () => {
|
|||||||
await vi.waitFor(() => expect(end).toHaveBeenCalled());
|
await vi.waitFor(() => expect(end).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds the host by sync id for a linked desktop", async () => {
|
||||||
|
mocks.resolveHostBySyncId.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
port: echoPort,
|
||||||
|
});
|
||||||
|
const { socket } = await open("?syncId=abc");
|
||||||
|
const received = new Promise<string>((resolve) =>
|
||||||
|
socket.once("message", (data) => resolve(data.toString())),
|
||||||
|
);
|
||||||
|
socket.send("ping");
|
||||||
|
expect(await received).toBe("ping");
|
||||||
|
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
|
||||||
|
expect(mocks.resolveHostById).not.toHaveBeenCalled();
|
||||||
|
socket.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a host the user can't connect to", async () => {
|
it("refuses a host the user can't connect to", async () => {
|
||||||
mocks.resolveHostById.mockResolvedValue(null);
|
mocks.resolveHostById.mockResolvedValue(null);
|
||||||
const { closed } = await open("?hostId=5");
|
const { closed } = await open("?hostId=5");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
parsePlatformProbe,
|
parsePlatformProbe,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
buildSudoCommand,
|
buildSudoCommand,
|
||||||
parseUpgradable,
|
parseUpgradable,
|
||||||
execCommand,
|
execCommand,
|
||||||
|
decodeCommandOutput,
|
||||||
} from "@termix-ssh/plugin-sdk/host-commands";
|
} from "@termix-ssh/plugin-sdk/host-commands";
|
||||||
|
|
||||||
describe("parsePlatformProbe", () => {
|
describe("parsePlatformProbe", () => {
|
||||||
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
class FakeStream extends EventEmitter {
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
destroy() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeClient(script: (stream: FakeStream) => void) {
|
||||||
|
return {
|
||||||
|
exec: (
|
||||||
|
_command: string,
|
||||||
|
_options: unknown,
|
||||||
|
callback: (err: Error | undefined, stream: FakeStream) => void,
|
||||||
|
) => {
|
||||||
|
const stream = new FakeStream();
|
||||||
|
callback(undefined, stream);
|
||||||
|
script(stream);
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "The system cannot find the path specified." as the GBK bytes a
|
||||||
|
// Chinese-locale Windows console actually sends.
|
||||||
|
const GBK_PATH_ERROR = Buffer.from(
|
||||||
|
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
|
||||||
|
"hex",
|
||||||
|
);
|
||||||
|
|
||||||
describe("execCommand", () => {
|
describe("execCommand", () => {
|
||||||
it("leaves the command out of the timeout error", async () => {
|
it("leaves the command out of the timeout error", async () => {
|
||||||
const client = { exec: () => {} } as never;
|
const client = { exec: () => {} } as never;
|
||||||
@@ -124,4 +152,64 @@ describe("execCommand", () => {
|
|||||||
/^Command timeout after 5ms$/,
|
/^Command timeout after 5ms$/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("decodes GBK stderr from a localized Windows host", async () => {
|
||||||
|
const result = await execCommand(
|
||||||
|
fakeClient((stream) => {
|
||||||
|
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
|
||||||
|
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
|
||||||
|
stream.emit("close", 1);
|
||||||
|
}),
|
||||||
|
"dir",
|
||||||
|
);
|
||||||
|
expect(result.stderr).toBe("系统找不到指定的路径。");
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reassembles a UTF-8 character split across chunks", async () => {
|
||||||
|
const utf8 = Buffer.from("以太网 2", "utf8");
|
||||||
|
const result = await execCommand(
|
||||||
|
fakeClient((stream) => {
|
||||||
|
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
|
||||||
|
stream.emit("data", utf8.subarray(4));
|
||||||
|
stream.emit("close", 0);
|
||||||
|
}),
|
||||||
|
"ipconfig",
|
||||||
|
);
|
||||||
|
expect(result.stdout).toBe("以太网 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes plain ASCII output through unchanged", async () => {
|
||||||
|
const result = await execCommand(
|
||||||
|
fakeClient((stream) => {
|
||||||
|
stream.emit("data", Buffer.from("ok\n"));
|
||||||
|
stream.stderr.emit("data", Buffer.from("warn\n"));
|
||||||
|
stream.emit("close", 0);
|
||||||
|
}),
|
||||||
|
"echo ok",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeCommandOutput", () => {
|
||||||
|
it("keeps valid UTF-8, including the empty buffer", () => {
|
||||||
|
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
|
||||||
|
"以太网 2",
|
||||||
|
);
|
||||||
|
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to GBK when the bytes are not valid UTF-8", () => {
|
||||||
|
// "以太网 2" in GBK, the interface name of the metrics widget issue.
|
||||||
|
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
|
||||||
|
"以太网 2",
|
||||||
|
);
|
||||||
|
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps lossy UTF-8 when no decoder matches", () => {
|
||||||
|
// 0xff is a valid byte in neither UTF-8 nor GBK.
|
||||||
|
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
isBareSpecifier,
|
isBareSpecifier,
|
||||||
isUserPluginFile,
|
isUserPluginFile,
|
||||||
|
shouldUseHostModule,
|
||||||
} from "../../plugins/host-modules.js";
|
} from "../../plugins/host-modules.js";
|
||||||
|
|
||||||
describe("isBareSpecifier", () => {
|
describe("isBareSpecifier", () => {
|
||||||
@@ -46,6 +47,36 @@ describe("isUserPluginFile", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shouldUseHostModule", () => {
|
||||||
|
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
|
||||||
|
const parent = pathToFileURL(
|
||||||
|
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
|
||||||
|
).href;
|
||||||
|
|
||||||
|
it("falls back for import and require misses from a plugin", () => {
|
||||||
|
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
|
||||||
|
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves other errors and files alone", () => {
|
||||||
|
expect(
|
||||||
|
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldUseHostModule(
|
||||||
|
{ code: "MODULE_NOT_FOUND" },
|
||||||
|
"axios",
|
||||||
|
undefined,
|
||||||
|
root,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("installHostModuleFallback", () => {
|
describe("installHostModuleFallback", () => {
|
||||||
let dataDir: string | null = null;
|
let dataDir: string | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classifyRuntimeError,
|
||||||
|
composeArgsFor,
|
||||||
|
containerCommand,
|
||||||
|
countContainers,
|
||||||
|
diffContainerStates,
|
||||||
|
dockerArgs,
|
||||||
|
parseComposeProjects,
|
||||||
|
parseContainerList,
|
||||||
|
parseContainerStates,
|
||||||
|
parseContainerStats,
|
||||||
|
parseRuntimeVersion,
|
||||||
|
parseServices,
|
||||||
|
parseStackStatus,
|
||||||
|
stackStateFromServices,
|
||||||
|
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||||
|
|
||||||
|
describe("containerCommand", () => {
|
||||||
|
it("extends PATH on POSIX hosts", () => {
|
||||||
|
const command = containerCommand("podman", "ps");
|
||||||
|
expect(command.startsWith('PATH="')).toBe(true);
|
||||||
|
expect(command.endsWith(" podman ps")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the plain CLI on Windows hosts", () => {
|
||||||
|
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to docker for an unknown runtime", () => {
|
||||||
|
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dockerArgs", () => {
|
||||||
|
it("builds the container commands", () => {
|
||||||
|
expect(dockerArgs.inspect("web")).toBe("inspect web");
|
||||||
|
expect(dockerArgs.action("restart", "web")).toBe("restart web");
|
||||||
|
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
|
||||||
|
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
|
||||||
|
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses container ids that could break the command line", () => {
|
||||||
|
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
|
||||||
|
expect(() => dockerArgs.logs("$(id)")).toThrow();
|
||||||
|
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
|
||||||
|
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only passes sane log filters", () => {
|
||||||
|
expect(
|
||||||
|
dockerArgs.logs("web", {
|
||||||
|
tail: 50.7,
|
||||||
|
timestamps: true,
|
||||||
|
since: "2026-10-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
|
||||||
|
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker parsing", () => {
|
||||||
|
it("reads the version and sorts errors", () => {
|
||||||
|
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
|
||||||
|
"27.3.1",
|
||||||
|
);
|
||||||
|
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
|
||||||
|
expect(parseRuntimeVersion("nope")).toBe("unknown");
|
||||||
|
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
|
||||||
|
"NOT_INSTALLED",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyRuntimeError(
|
||||||
|
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
|
||||||
|
),
|
||||||
|
).toBe("DAEMON_NOT_RUNNING");
|
||||||
|
expect(
|
||||||
|
classifyRuntimeError("permission denied while trying to connect"),
|
||||||
|
).toBe("PERMISSION_DENIED");
|
||||||
|
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips lines that are not JSON in the list", () => {
|
||||||
|
const rows = parseContainerList(
|
||||||
|
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the stats columns", () => {
|
||||||
|
const stats = parseContainerStats(
|
||||||
|
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||||
|
);
|
||||||
|
expect(stats).toMatchObject({
|
||||||
|
cpu: "1.5%",
|
||||||
|
memoryUsed: "10MiB",
|
||||||
|
memoryLimit: "1GiB",
|
||||||
|
netInput: "1kB",
|
||||||
|
netOutput: "2kB",
|
||||||
|
pids: "3",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const ps = [
|
||||||
|
`{"name":"web","state":"running","status":"Up 2 hours"}`,
|
||||||
|
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
|
||||||
|
`{"name":"old","state":"exited","status":"Exited (0)"}`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("counts running, stopped and unhealthy containers", () => {
|
||||||
|
expect(countContainers(ps)).toEqual({
|
||||||
|
running: 2,
|
||||||
|
stopped: 1,
|
||||||
|
unhealthy: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns two snapshots into events", () => {
|
||||||
|
const before = parseContainerStates(ps);
|
||||||
|
const after = parseContainerStates(
|
||||||
|
[
|
||||||
|
`{"name":"web","state":"exited","status":"Exited (1)"}`,
|
||||||
|
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
|
||||||
|
`{"name":"old","state":"running","status":"Up 1 second"}`,
|
||||||
|
`{"name":"new","state":"running","status":"Up"}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(diffContainerStates(before, after)).toEqual([
|
||||||
|
{ container: "web", event: "exited" },
|
||||||
|
{ container: "old", event: "started" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("compose", () => {
|
||||||
|
it("reads stack status text", () => {
|
||||||
|
expect(parseStackStatus("running(2), exited(1)")).toEqual({
|
||||||
|
state: "partial",
|
||||||
|
running: 2,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
|
||||||
|
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads projects from compose ls", () => {
|
||||||
|
const stacks = parseComposeProjects(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
Name: "web",
|
||||||
|
Status: "running(2)",
|
||||||
|
ConfigFiles: "/opt/stacks/web/compose.yaml",
|
||||||
|
},
|
||||||
|
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(stacks).toEqual([
|
||||||
|
{
|
||||||
|
name: "web",
|
||||||
|
managed: false,
|
||||||
|
state: "running",
|
||||||
|
running: 2,
|
||||||
|
total: 2,
|
||||||
|
composeFile: "/opt/stacks/web/compose.yaml",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads services from compose ps, one line or one array", () => {
|
||||||
|
const row = {
|
||||||
|
Service: "app",
|
||||||
|
Name: "web-app-1",
|
||||||
|
ID: "abc",
|
||||||
|
Image: "nginx",
|
||||||
|
State: "running",
|
||||||
|
Status: "Up",
|
||||||
|
Health: "",
|
||||||
|
Publishers: [
|
||||||
|
{ PublishedPort: 8080, TargetPort: 80 },
|
||||||
|
{ PublishedPort: 8080, TargetPort: 80 },
|
||||||
|
{ PublishedPort: 0, TargetPort: 443 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const lines = parseServices(JSON.stringify(row));
|
||||||
|
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
|
||||||
|
expect(
|
||||||
|
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||||
|
).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
stackStateFromServices(
|
||||||
|
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
state: "partial",
|
||||||
|
running: 1,
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps actions to compose commands", () => {
|
||||||
|
expect(composeArgsFor("redeploy", "app", {})).toEqual([
|
||||||
|
"pull app",
|
||||||
|
"up -d --remove-orphans app",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
composeArgsFor("destroy", undefined, { removeVolumes: true }),
|
||||||
|
).toEqual(["down --remove-orphans -v"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildFilesystemList,
|
||||||
|
filterExcludedFilesystems,
|
||||||
|
findWorstMountIndex,
|
||||||
|
liveCmd,
|
||||||
|
mergeMonitoredFilesystems,
|
||||||
|
parseCpuLine,
|
||||||
|
parseDfLines,
|
||||||
|
parseLive,
|
||||||
|
selectPrimaryFilesystem,
|
||||||
|
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||||
|
|
||||||
|
describe("parseCpuLine", () => {
|
||||||
|
it("parses a standard /proc/stat cpu line", () => {
|
||||||
|
// user nice system idle iowait irq softirq
|
||||||
|
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
// idle = idle(800) + iowait(30)
|
||||||
|
expect(result?.idle).toBe(830);
|
||||||
|
// total = sum of all fields
|
||||||
|
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates leading/trailing whitespace", () => {
|
||||||
|
const result = parseCpuLine(" cpu 1 2 3 4 ");
|
||||||
|
expect(result?.total).toBe(10);
|
||||||
|
expect(result?.idle).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-cpu lines", () => {
|
||||||
|
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
|
||||||
|
expect(parseCpuLine("intr 12345")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there are fewer than 4 numeric fields", () => {
|
||||||
|
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDfLines", () => {
|
||||||
|
it("parses df -T -P output into rows", () => {
|
||||||
|
const output =
|
||||||
|
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||||
|
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
|
||||||
|
const rows = parseDfLines(output);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].mount).toBe("/");
|
||||||
|
expect(rows[0].type).toBe("ext4");
|
||||||
|
expect(rows[1].mount).toBe("/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out pseudo filesystems", () => {
|
||||||
|
const output =
|
||||||
|
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||||
|
"overlay overlay 100 50 50 50% /\n" +
|
||||||
|
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
|
||||||
|
const rows = parseDfLines(output);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].mount).toBe("/mnt/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the filesystem type for network shares", () => {
|
||||||
|
const output =
|
||||||
|
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||||
|
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
|
||||||
|
const rows = parseDfLines(output);
|
||||||
|
expect(rows[0].type).toBe("nfs4");
|
||||||
|
expect(rows[1].type).toBe("cifs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps full image and firmware mounts out of disk usage", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
|
||||||
|
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
|
||||||
|
"/dev/sda1 ext4 1000 400 600 40% /\n",
|
||||||
|
);
|
||||||
|
expect(rows.map((row) => row.mount)).toEqual(["/"]);
|
||||||
|
expect(findWorstMountIndex(rows)).toEqual({
|
||||||
|
index: 0,
|
||||||
|
usedBytes: 400,
|
||||||
|
totalBytes: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains real storage without requiring a /dev source", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
|
||||||
|
"tank/data zfs 2000 1000 1000 50% /data\n" +
|
||||||
|
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||||
|
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||||
|
);
|
||||||
|
expect(rows.map((row) => row.type)).toEqual([
|
||||||
|
"btrfs",
|
||||||
|
"zfs",
|
||||||
|
"nfs4",
|
||||||
|
"cifs",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findWorstMountIndex", () => {
|
||||||
|
it("picks the most-utilized mount, not just the first row", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
|
||||||
|
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
|
||||||
|
);
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(1);
|
||||||
|
expect(worst.totalBytes).toBe(15393162788864);
|
||||||
|
expect(worst.usedBytes).toBe(15239230844928);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the only mount available", () => {
|
||||||
|
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with invalid or zero totals", () => {
|
||||||
|
const rows = parseDfLines(
|
||||||
|
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
|
||||||
|
"/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||||
|
);
|
||||||
|
const worst = findWorstMountIndex(rows);
|
||||||
|
expect(worst.index).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns index -1 when there are no usable rows", () => {
|
||||||
|
const worst = findWorstMountIndex([]);
|
||||||
|
expect(worst.index).toBe(-1);
|
||||||
|
expect(worst.totalBytes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const BYTES_OUTPUT =
|
||||||
|
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
|
||||||
|
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
|
||||||
|
const HUMAN_OUTPUT =
|
||||||
|
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
|
||||||
|
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
|
||||||
|
|
||||||
|
describe("buildFilesystemList", () => {
|
||||||
|
it("returns every real filesystem with byte maths and human strings", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(BYTES_OUTPUT),
|
||||||
|
parseDfLines(HUMAN_OUTPUT),
|
||||||
|
);
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
expect(list[0]).toMatchObject({
|
||||||
|
mount: "/",
|
||||||
|
type: "ext4",
|
||||||
|
percent: 40,
|
||||||
|
usedHuman: "400",
|
||||||
|
totalHuman: "1.0K",
|
||||||
|
availableHuman: "600",
|
||||||
|
usedBytes: 400,
|
||||||
|
totalBytes: 1000,
|
||||||
|
});
|
||||||
|
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches human rows by mount when the row counts differ", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(BYTES_OUTPUT),
|
||||||
|
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
|
||||||
|
);
|
||||||
|
expect(list[0].totalHuman).toBeNull();
|
||||||
|
expect(list[1].totalHuman).toBe("2.0K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops filesystems with a zero or invalid total", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(
|
||||||
|
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].mount).toBe("/ok");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectPrimaryFilesystem", () => {
|
||||||
|
it("prefers root over a fuller secondary mount", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(BYTES_OUTPUT),
|
||||||
|
parseDfLines(HUMAN_OUTPUT),
|
||||||
|
);
|
||||||
|
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the most-utilized mount when there is no root", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(
|
||||||
|
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
|
||||||
|
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty list", () => {
|
||||||
|
expect(selectPrimaryFilesystem([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterExcludedFilesystems", () => {
|
||||||
|
const list = buildFilesystemList(
|
||||||
|
parseDfLines(
|
||||||
|
"/dev/sda1 ext4 1000 400 600 40% /\n" +
|
||||||
|
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
|
||||||
|
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns the same list when no mounts are excluded", () => {
|
||||||
|
expect(filterExcludedFilesystems(list)).toHaveLength(3);
|
||||||
|
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes an exact mount path match", () => {
|
||||||
|
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
|
||||||
|
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes by filesystem type substring, case-insensitively", () => {
|
||||||
|
const filtered = filterExcludedFilesystems(list, ["NFS"]);
|
||||||
|
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports excluding multiple network filesystem types at once", () => {
|
||||||
|
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
|
||||||
|
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores blank/whitespace-only entries", () => {
|
||||||
|
const filtered = filterExcludedFilesystems(list, [" ", ""]);
|
||||||
|
expect(filtered).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeMonitoredFilesystems", () => {
|
||||||
|
it("adds an arbitrary path with a user label", () => {
|
||||||
|
const detected = buildFilesystemList(
|
||||||
|
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const custom = buildFilesystemList(
|
||||||
|
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const result = mergeMonitoredFilesystems(
|
||||||
|
detected,
|
||||||
|
[{ path: "/config", label: "Home Assistant" }],
|
||||||
|
custom,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
mount: "/config",
|
||||||
|
label: "Home Assistant",
|
||||||
|
totalBytes: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a path that is already a detected mount", () => {
|
||||||
|
const detected = buildFilesystemList(
|
||||||
|
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const result = mergeMonitoredFilesystems(
|
||||||
|
detected,
|
||||||
|
[{ path: "/data", label: "Media" }],
|
||||||
|
detected,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].label).toBe("Media");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = (
|
||||||
|
agg: [number, number],
|
||||||
|
core: [number, number],
|
||||||
|
rx: number,
|
||||||
|
up: number,
|
||||||
|
procs: string,
|
||||||
|
page = "4096",
|
||||||
|
) =>
|
||||||
|
[
|
||||||
|
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
|
||||||
|
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
|
||||||
|
`0.1 0.2 0.3 1/2 3\n${up} 5`,
|
||||||
|
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
|
||||||
|
page,
|
||||||
|
procs,
|
||||||
|
].join("\n@@\n");
|
||||||
|
|
||||||
|
const proc = (pid: number, ticks: number, start: number, name = "java") =>
|
||||||
|
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
|
||||||
|
|
||||||
|
describe("parseLive extras", () => {
|
||||||
|
it("reports state, parent, CPU mix, cache and the running count", () => {
|
||||||
|
const mk = (user: number, sys: number, wait: number, idle: number) =>
|
||||||
|
out([user, idle], [user, idle], 1, 10, procs).replace(
|
||||||
|
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
|
||||||
|
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
|
||||||
|
);
|
||||||
|
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
|
||||||
|
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
|
||||||
|
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
|
||||||
|
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
|
||||||
|
expect(b.sample.running).toBe(1);
|
||||||
|
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
|
||||||
|
state: "S",
|
||||||
|
ppid: 5,
|
||||||
|
});
|
||||||
|
expect(b.sample.memCachedKb).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liveCmd", () => {
|
||||||
|
it("asks for the page size once, then passes the cached number", () => {
|
||||||
|
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
|
||||||
|
expect(liveCmd(16384)).toContain("pg=16384 ");
|
||||||
|
expect(liveCmd(16384)).not.toContain("getconf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLive", () => {
|
||||||
|
it("has no rates on the first sample", () => {
|
||||||
|
const { sample } = parseLive(
|
||||||
|
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(sample.cpu).toBeNull();
|
||||||
|
expect(sample.net[0].rxBps).toBeNull();
|
||||||
|
expect(sample.memUsedKb).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives cpu, per-core, net and process rates from the delta", () => {
|
||||||
|
const a = parseLive(
|
||||||
|
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
|
||||||
|
const { sample } = parseLive(
|
||||||
|
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
|
||||||
|
a.next,
|
||||||
|
);
|
||||||
|
expect(sample.cpu).toBeCloseTo(50, 0);
|
||||||
|
expect(sample.cores[0]).toBeCloseTo(25, 0);
|
||||||
|
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
|
||||||
|
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
|
||||||
|
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not charge a reused pid with the old process's ticks", () => {
|
||||||
|
const a = parseLive(
|
||||||
|
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
// same pid, new start time, lower ticks must not look like a huge delta
|
||||||
|
const { sample } = parseLive(
|
||||||
|
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
|
||||||
|
a.next,
|
||||||
|
);
|
||||||
|
expect(sample.procs[0].cpu).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
|
||||||
|
const a = parseLive(
|
||||||
|
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(a.next.pageBytes).toBe(16384);
|
||||||
|
const b = parseLive(
|
||||||
|
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
|
||||||
|
a.next,
|
||||||
|
);
|
||||||
|
expect(b.next.pageBytes).toBe(16384);
|
||||||
|
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts rates again after a reboot", () => {
|
||||||
|
const a = parseLive(
|
||||||
|
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const { sample } = parseLive(
|
||||||
|
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
|
||||||
|
a.next,
|
||||||
|
);
|
||||||
|
expect(sample.cpu).toBeNull();
|
||||||
|
expect(sample.net[0].rxBps).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isUnit,
|
||||||
|
journalArgs,
|
||||||
|
parseBoots,
|
||||||
|
parseDetect,
|
||||||
|
parseEntry,
|
||||||
|
parseIsActive,
|
||||||
|
parseJournal,
|
||||||
|
parseJournalQuery,
|
||||||
|
parseOpenRcList,
|
||||||
|
parseSystemdTime,
|
||||||
|
parseTimers,
|
||||||
|
parseUnitList,
|
||||||
|
parseUsage,
|
||||||
|
shellQuote as q,
|
||||||
|
vacuumScript,
|
||||||
|
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||||
|
|
||||||
|
describe("names", () => {
|
||||||
|
it("quotes for bash", () => {
|
||||||
|
expect(q("a'b")).toBe(`'a'\\''b'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes unit names and refuses anything else", () => {
|
||||||
|
expect(isUnit("nginx.service")).toBe(true);
|
||||||
|
expect(isUnit("getty@tty1.service")).toBe(true);
|
||||||
|
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
|
||||||
|
expect(isUnit("-rf.service")).toBe(false);
|
||||||
|
expect(isUnit("a;b.service")).toBe(false);
|
||||||
|
expect(isUnit("a b.service")).toBe(false);
|
||||||
|
expect(isUnit("nginx")).toBe(false);
|
||||||
|
expect(isUnit("$(id).service")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("systemd parsing", () => {
|
||||||
|
const list = [
|
||||||
|
"cron.service loaded active running Regular background program",
|
||||||
|
"● bad.service loaded failed failed Broken thing",
|
||||||
|
"getty@tty1.service loaded active running Getty on tty1",
|
||||||
|
"@@TERMIX_FILES@@",
|
||||||
|
"cron.service enabled enabled",
|
||||||
|
"bad.service disabled enabled",
|
||||||
|
"getty@.service enabled enabled",
|
||||||
|
"old.service masked -",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("merges loaded units with unit files and skips templates", () => {
|
||||||
|
const units = parseUnitList(list);
|
||||||
|
expect(units.map((u) => u.name)).toEqual([
|
||||||
|
"bad.service",
|
||||||
|
"cron.service",
|
||||||
|
"getty@tty1.service",
|
||||||
|
"old.service",
|
||||||
|
]);
|
||||||
|
const cron = units.find((u) => u.name === "cron.service")!;
|
||||||
|
expect(cron).toMatchObject({
|
||||||
|
active: "active",
|
||||||
|
sub: "running",
|
||||||
|
description: "Regular background program",
|
||||||
|
fileState: "enabled",
|
||||||
|
preset: "enabled",
|
||||||
|
});
|
||||||
|
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
|
||||||
|
expect(units.find((u) => u.name === "old.service")).toMatchObject({
|
||||||
|
load: "masked",
|
||||||
|
active: "inactive",
|
||||||
|
preset: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads UTC times", () => {
|
||||||
|
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
|
||||||
|
Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||||
|
);
|
||||||
|
expect(parseSystemdTime("")).toBeNull();
|
||||||
|
expect(parseSystemdTime("n/a")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads timers from systemctl show blocks", () => {
|
||||||
|
const out = [
|
||||||
|
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
|
||||||
|
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
|
||||||
|
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
|
||||||
|
"Id=apt-daily.timer",
|
||||||
|
"Triggers=apt-daily.service",
|
||||||
|
"ActiveState=active",
|
||||||
|
"",
|
||||||
|
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
|
||||||
|
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
|
||||||
|
"NextElapseUSecRealtime=",
|
||||||
|
"LastTriggerUSec=",
|
||||||
|
"Id=clean.timer",
|
||||||
|
"Triggers=clean.service",
|
||||||
|
].join("\n");
|
||||||
|
const timers = parseTimers(out);
|
||||||
|
expect(timers[0]).toMatchObject({
|
||||||
|
name: "apt-daily.timer",
|
||||||
|
activates: "apt-daily.service",
|
||||||
|
next: Date.UTC(2026, 9, 22, 2, 3, 51),
|
||||||
|
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
|
||||||
|
});
|
||||||
|
expect(timers[1]).toMatchObject({
|
||||||
|
name: "clean.timer",
|
||||||
|
next: null,
|
||||||
|
last: null,
|
||||||
|
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps is-active lines to units", () => {
|
||||||
|
const states = parseIsActive(
|
||||||
|
["a.service", "b.service"],
|
||||||
|
"active\nfailed\n",
|
||||||
|
);
|
||||||
|
expect(states.get("a.service")).toBe("active");
|
||||||
|
expect(states.get("b.service")).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads what detect prints", () => {
|
||||||
|
expect(
|
||||||
|
parseDetect(
|
||||||
|
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
init: "systemd",
|
||||||
|
version: "257",
|
||||||
|
root: true,
|
||||||
|
journal: true,
|
||||||
|
failed: 2,
|
||||||
|
});
|
||||||
|
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
|
||||||
|
init: "none",
|
||||||
|
root: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("journal", () => {
|
||||||
|
it("checks filters", () => {
|
||||||
|
const query = parseJournalQuery({
|
||||||
|
unit: ["nginx.service", "ssh.service"],
|
||||||
|
priority: "3",
|
||||||
|
since: "-1h",
|
||||||
|
boot: "-1",
|
||||||
|
grep: "fail'ed",
|
||||||
|
lines: "99999",
|
||||||
|
});
|
||||||
|
expect(query.lines).toBe(5000);
|
||||||
|
const args = journalArgs(query);
|
||||||
|
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
|
||||||
|
expect(args).toContain("-p 3");
|
||||||
|
expect(args).toContain("--since '-1h'");
|
||||||
|
expect(args).toContain("-b '-1'");
|
||||||
|
expect(args).toContain(`-g 'fail'\\''ed'`);
|
||||||
|
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
|
||||||
|
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
|
||||||
|
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
|
||||||
|
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses entries, binary messages included", () => {
|
||||||
|
const entry = parseEntry(
|
||||||
|
JSON.stringify({
|
||||||
|
__REALTIME_TIMESTAMP: "1792612429684000",
|
||||||
|
PRIORITY: "3",
|
||||||
|
_SYSTEMD_UNIT: "x.service",
|
||||||
|
SYSLOG_IDENTIFIER: "x",
|
||||||
|
_PID: "12",
|
||||||
|
MESSAGE: [104, 105],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(entry).toEqual({
|
||||||
|
time: 1792612429684,
|
||||||
|
priority: 3,
|
||||||
|
unit: "x.service",
|
||||||
|
ident: "x",
|
||||||
|
pid: "12",
|
||||||
|
message: "hi",
|
||||||
|
});
|
||||||
|
expect(parseEntry("not json")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters here when journalctl has no -g", () => {
|
||||||
|
const line = (message: string) =>
|
||||||
|
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
|
||||||
|
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
|
||||||
|
const query = parseJournalQuery({ grep: "error", lines: "10" });
|
||||||
|
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
|
||||||
|
"Disk Error",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads boots and disk use", () => {
|
||||||
|
const boots = parseBoots(
|
||||||
|
[
|
||||||
|
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
|
||||||
|
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
|
||||||
|
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
|
||||||
|
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
|
||||||
|
expect(
|
||||||
|
parseUsage(
|
||||||
|
"Archived and active journals take up 378.6M in the file system.",
|
||||||
|
),
|
||||||
|
).toBe("378.6M");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only takes sane vacuum values", () => {
|
||||||
|
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
|
||||||
|
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
|
||||||
|
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
|
||||||
|
expect(() => vacuumScript({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openrc", () => {
|
||||||
|
it("maps services and runlevels", () => {
|
||||||
|
const units = parseOpenRcList(
|
||||||
|
[
|
||||||
|
" sshd [ started ]",
|
||||||
|
" crond [ stopped ]",
|
||||||
|
" nginx [ crashed ]",
|
||||||
|
"@@TERMIX_RUNLEVELS@@",
|
||||||
|
" sshd | default",
|
||||||
|
" nginx | ",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(units.find((u) => u.name === "sshd")).toMatchObject({
|
||||||
|
active: "active",
|
||||||
|
fileState: "enabled",
|
||||||
|
});
|
||||||
|
expect(units.find((u) => u.name === "nginx")).toMatchObject({
|
||||||
|
active: "failed",
|
||||||
|
fileState: "disabled",
|
||||||
|
});
|
||||||
|
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
actionScript,
|
||||||
|
errLine,
|
||||||
|
isActiveScript,
|
||||||
|
listUnitsScript,
|
||||||
|
openRcActionScript,
|
||||||
|
parseKeys,
|
||||||
|
passwordSudoScript,
|
||||||
|
scriptError,
|
||||||
|
scriptShell,
|
||||||
|
SUDO_REFUSED_RE,
|
||||||
|
SUDO_WRONG_RE,
|
||||||
|
toBase64,
|
||||||
|
utf8Decode,
|
||||||
|
utf8Encode,
|
||||||
|
watchdog,
|
||||||
|
} from "@termix-ssh/plugin-sdk/host-scripts";
|
||||||
|
|
||||||
|
describe("text helpers", () => {
|
||||||
|
it("encodes base64 like Buffer does", () => {
|
||||||
|
for (const text of [
|
||||||
|
"",
|
||||||
|
"a",
|
||||||
|
"ab",
|
||||||
|
"abc",
|
||||||
|
"héllo wörld",
|
||||||
|
"emoji 🙂 here",
|
||||||
|
"line\nbreak",
|
||||||
|
]) {
|
||||||
|
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round trips UTF-8 and replaces bad bytes", () => {
|
||||||
|
const text = "plain, ünïcode, 日本, 🙂";
|
||||||
|
expect(utf8Decode(utf8Encode(text))).toBe(text);
|
||||||
|
expect(Array.from(utf8Encode(text))).toEqual(
|
||||||
|
Array.from(Buffer.from(text, "utf8")),
|
||||||
|
);
|
||||||
|
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
|
||||||
|
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads key=value lines", () => {
|
||||||
|
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("script shells", () => {
|
||||||
|
it("always reads the script from stdin", () => {
|
||||||
|
for (const mode of ["plain", "auto", "root"] as const) {
|
||||||
|
expect(scriptShell(mode)).toContain("bash -s");
|
||||||
|
}
|
||||||
|
expect(scriptShell("auto")).toContain("sudo -n true");
|
||||||
|
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects error lines, old and new markers", () => {
|
||||||
|
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
|
||||||
|
"one\ntwo",
|
||||||
|
);
|
||||||
|
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches sudo refusals", () => {
|
||||||
|
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
|
||||||
|
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the sudo password out of the command line", () => {
|
||||||
|
const { command, stdin } = passwordSudoScript(
|
||||||
|
"systemctl restart 'a.service'\n",
|
||||||
|
"s3cr'et $(id)",
|
||||||
|
);
|
||||||
|
expect(command).not.toContain("s3cr");
|
||||||
|
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
|
||||||
|
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
|
||||||
|
expect(stdin).toContain("sudo -k -S -p ''");
|
||||||
|
expect(stdin).not.toContain("systemctl restart");
|
||||||
|
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"runs the script and never runs the password",
|
||||||
|
() => {
|
||||||
|
const { stdin } = passwordSudoScript(
|
||||||
|
'echo "ran:$1${TERMIX_PW:-none}"\n',
|
||||||
|
"touch /tmp/should-not-exist",
|
||||||
|
);
|
||||||
|
// No sudo here: swap it for a fake that drops its own stdin line.
|
||||||
|
const fake = stdin.replace(
|
||||||
|
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
|
||||||
|
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
|
||||||
|
);
|
||||||
|
expect(fake).not.toBe(stdin);
|
||||||
|
const out = execFileSync(
|
||||||
|
"bash",
|
||||||
|
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
|
||||||
|
{
|
||||||
|
input: fake,
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(out.trim()).toBe("ran:none");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unit scripts", () => {
|
||||||
|
it("quotes units and refuses bad names", () => {
|
||||||
|
expect(actionScript("a.service", "enable-now")).toContain(
|
||||||
|
"systemctl enable --now -- 'a.service'",
|
||||||
|
);
|
||||||
|
expect(() => actionScript("a;b.service", "start")).toThrow();
|
||||||
|
expect(isActiveScript(["a.service", "b.timer"])).toContain(
|
||||||
|
"-- 'a.service' 'b.timer'",
|
||||||
|
);
|
||||||
|
expect(() => listUnitsScript("bogus" as never)).toThrow();
|
||||||
|
expect(openRcActionScript("sshd", "enable")).toContain(
|
||||||
|
"rc-update add 'sshd' default",
|
||||||
|
);
|
||||||
|
expect(() => openRcActionScript("sshd", "mask")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops a watched command once stdin closes", () => {
|
||||||
|
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
|
|||||||
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a signature that does not match the file", async () => {
|
||||||
|
await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
await expect(
|
||||||
|
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
|
||||||
|
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a signature from a non official key unverified", async () => {
|
||||||
|
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
|
setCustomPluginKeys([
|
||||||
|
{
|
||||||
|
id: "mine",
|
||||||
|
publicKey: publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64"),
|
||||||
|
addedIn: "26.10.6",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await boot();
|
||||||
|
await manage.setDeveloperMode(true);
|
||||||
|
setCustomPluginKeys([
|
||||||
|
{
|
||||||
|
id: "mine",
|
||||||
|
publicKey: publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.subarray(12)
|
||||||
|
.toString("base64"),
|
||||||
|
addedIn: "26.10.6",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const buffer = await artifactBuffer("notes");
|
||||||
|
const digest = crypto.createHash("sha256").update(buffer).digest();
|
||||||
|
const signature = crypto
|
||||||
|
.sign(null, digest, privateKey)
|
||||||
|
.toString("base64");
|
||||||
|
const preview = await manage.stageUpload(buffer, signature);
|
||||||
|
expect(preview.official).toBe(false);
|
||||||
|
await manage.installUpload(preview.token, {
|
||||||
|
userId: "admin",
|
||||||
|
capabilities: preview.capabilities,
|
||||||
|
});
|
||||||
|
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
|
||||||
|
} finally {
|
||||||
|
setCustomPluginKeys([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a file that is not a plugin archive", async () => {
|
it("refuses a file that is not a plugin archive", async () => {
|
||||||
await boot();
|
await boot();
|
||||||
await manage.setDeveloperMode(true);
|
await manage.setDeveloperMode(true);
|
||||||
|
|||||||
@@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("contributes.mobile", () => {
|
||||||
|
const tabs = [
|
||||||
|
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("accepts each mode with its own fields", () => {
|
||||||
|
for (const mobile of [
|
||||||
|
{
|
||||||
|
mode: "webview",
|
||||||
|
entry: "dist/mobile.js",
|
||||||
|
sizes: ["phone", "tablet"],
|
||||||
|
views: ["sample"],
|
||||||
|
note: "mobile.note",
|
||||||
|
},
|
||||||
|
{ mode: "webview" },
|
||||||
|
{ mode: "native", feature: "file-manager" },
|
||||||
|
{ mode: "none", note: "Not on phones" },
|
||||||
|
]) {
|
||||||
|
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs a known mode and rejects unknown fields", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
|
||||||
|
).join();
|
||||||
|
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
|
||||||
|
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
|
||||||
|
expect(
|
||||||
|
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
|
||||||
|
).toMatch(/"contributes\.mobile" must be an object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps webview and native fields to their own mode", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
base({
|
||||||
|
contributes: {
|
||||||
|
tabs,
|
||||||
|
mobile: {
|
||||||
|
mode: "native",
|
||||||
|
entry: "dist/mobile.js",
|
||||||
|
views: ["sample"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).join();
|
||||||
|
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
|
||||||
|
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
|
||||||
|
).join(),
|
||||||
|
).toMatch(/mobile\.feature is only for mode "native"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the entry beside the frontend bundle", () => {
|
||||||
|
const check = (entry: string, frontend?: string) =>
|
||||||
|
validateManifest(
|
||||||
|
base({
|
||||||
|
...(frontend ? { frontend } : {}),
|
||||||
|
contributes: { mobile: { mode: "webview", entry } },
|
||||||
|
}),
|
||||||
|
).join();
|
||||||
|
expect(check("dist/mobile.js")).toBe("");
|
||||||
|
expect(check("./dist/mobile.js")).toBe("");
|
||||||
|
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
|
||||||
|
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
|
||||||
|
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
|
||||||
|
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks sizes, views and the feature id", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
base({
|
||||||
|
contributes: {
|
||||||
|
tabs,
|
||||||
|
panels: [{ id: "side", titleKey: "nav.side" }],
|
||||||
|
mobile: {
|
||||||
|
mode: "webview",
|
||||||
|
sizes: ["phone", "phone", "watch"],
|
||||||
|
views: ["sample", "side", "missing"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).join();
|
||||||
|
expect(errors).toMatch(/sizes duplicates "phone"/);
|
||||||
|
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
|
||||||
|
expect(errors).toMatch(/views entries must be ids .*"missing"/);
|
||||||
|
expect(errors).not.toMatch(/"side"/);
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
|
||||||
|
).join(),
|
||||||
|
).toMatch(/sizes must be a non-empty array/);
|
||||||
|
expect(
|
||||||
|
validateManifest(
|
||||||
|
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
|
||||||
|
).join(),
|
||||||
|
).toMatch(/mobile\.feature must match/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("named service providers", () => {
|
describe("named service providers", () => {
|
||||||
const provides = (names: unknown) => [
|
const provides = (names: unknown) => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
|
|||||||
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
expect(getEntity("thing")?.readOnly).toBeUndefined();
|
||||||
expect(getEntity("thing")?.load).toBeUndefined();
|
expect(getEntity("thing")?.load).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a plugin sync rows it builds as read only copies", () => {
|
||||||
|
const load = async () => [];
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "sharedIcons",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
load,
|
||||||
|
write: async () => undefined,
|
||||||
|
erase: async () => undefined,
|
||||||
|
});
|
||||||
|
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plugin entity that loads rows but can be pushed", () => {
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "pushable",
|
||||||
|
table: null,
|
||||||
|
load: async () => [],
|
||||||
|
write: async () => undefined,
|
||||||
|
erase: async () => undefined,
|
||||||
|
}),
|
||||||
|
).toThrow(/readOnly/);
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "halfDone",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
load: async () => [],
|
||||||
|
}),
|
||||||
|
).toThrow(/load, write and erase/);
|
||||||
|
expect(() =>
|
||||||
|
registerEntity("demo", { type: "nothing", table: null }),
|
||||||
|
).toThrow(/table or load/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops core only keys a plugin puts on its registration", () => {
|
||||||
|
registerEntity("demo", {
|
||||||
|
type: "sneaky",
|
||||||
|
table: {},
|
||||||
|
covers: () => true,
|
||||||
|
canReference: async () => true,
|
||||||
|
hashIgnore: ["x"],
|
||||||
|
} as never);
|
||||||
|
const entity = getEntity("sneaky")!;
|
||||||
|
expect(entity.covers).toBeUndefined();
|
||||||
|
expect(entity.canReference).toBeUndefined();
|
||||||
|
expect(entity.hashIgnore).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("core sync entities", () => {
|
describe("core sync entities", () => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getLink: vi.fn(),
|
getLink: vi.fn(),
|
||||||
generateJWTToken: vi.fn(),
|
generateJWTToken: vi.fn(),
|
||||||
revokeSession: vi.fn(),
|
revokeSession: vi.fn(),
|
||||||
|
getEntity: vi.fn(),
|
||||||
|
findStoredIds: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../utils/auth-manager.js", () => ({
|
vi.mock("../../utils/auth-manager.js", () => ({
|
||||||
@@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({
|
|||||||
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
|
||||||
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||||
vi.mock("../../plugins/sync-registry.js", () => ({
|
vi.mock("../../plugins/sync-registry.js", () => ({
|
||||||
getEntity: vi.fn(),
|
getEntity: mocks.getEntity,
|
||||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||||
onEntitiesChanged: vi.fn(),
|
onEntitiesChanged: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
|||||||
vi.mock("../../sync/entities.js", () => ({
|
vi.mock("../../sync/entities.js", () => ({
|
||||||
registerCoreSyncEntities: vi.fn(),
|
registerCoreSyncEntities: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../sync/store.js", () => ({}));
|
vi.mock("../../sync/store.js", () => ({
|
||||||
|
findStoredIds: mocks.findStoredIds,
|
||||||
|
}));
|
||||||
vi.mock("../../sync/wire.js", () => ({}));
|
vi.mock("../../sync/wire.js", () => ({}));
|
||||||
vi.mock("../../sync/records.js", () => ({
|
vi.mock("../../sync/records.js", () => ({
|
||||||
listChanges: mocks.listChanges,
|
listChanges: mocks.listChanges,
|
||||||
@@ -215,3 +219,22 @@ describe("host.links", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("host.entities.idsOf", () => {
|
||||||
|
it("maps sync ids to the user's own row ids", async () => {
|
||||||
|
const entity = { type: "snippets", table: {} };
|
||||||
|
mocks.getEntity.mockReturnValue(entity);
|
||||||
|
mocks.findStoredIds.mockResolvedValue({ a: 3 });
|
||||||
|
expect(
|
||||||
|
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
|
||||||
|
).toEqual({ a: 3 });
|
||||||
|
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers nothing for an unknown or built type", async () => {
|
||||||
|
mocks.getEntity.mockReturnValue(undefined);
|
||||||
|
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
|
||||||
|
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
|
||||||
|
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
authLogger: { info: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
|
||||||
|
|
||||||
|
const TEMPLATE = [
|
||||||
|
"listen ${PORT};",
|
||||||
|
"listen ${SSL_PORT} ssl;",
|
||||||
|
"ssl_certificate ${SSL_CERT_PATH};",
|
||||||
|
" root /app/html;",
|
||||||
|
"pid /tmp/nginx/nginx.pid;",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
port: "8080",
|
||||||
|
sslPort: "443",
|
||||||
|
certPath: "/data/ssl/termix.crt",
|
||||||
|
keyPath: "/data/ssl/termix.key",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("renderNginxTemplate", () => {
|
||||||
|
it("fills the ports and certificate", () => {
|
||||||
|
const out = renderNginxTemplate(TEMPLATE, values);
|
||||||
|
expect(out).toContain("listen 8080;");
|
||||||
|
expect(out).toContain("listen 443 ssl;");
|
||||||
|
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
|
||||||
|
expect(out).toContain("root /app/html;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the install folder in place of /app", () => {
|
||||||
|
const out = renderNginxTemplate(TEMPLATE, {
|
||||||
|
...values,
|
||||||
|
appDir: "/opt/termix/",
|
||||||
|
});
|
||||||
|
expect(out).toContain("root /opt/termix/html;");
|
||||||
|
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
hostOverride: null as number | null,
|
||||||
|
folderOverride: null as { folder: string; credentialId: number } | null,
|
||||||
|
ownerSecret: null as Record<string, unknown> | null,
|
||||||
|
usable: new Set<number>(),
|
||||||
|
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
|
||||||
|
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async (id: number) =>
|
||||||
|
state.usable.has(id) ? { id, username: `user-${id}` } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostOwnerId: async () => "owner",
|
||||||
|
}),
|
||||||
|
createCurrentRbacAccessRepository: () => ({
|
||||||
|
findActiveHostAccess: async () => null,
|
||||||
|
}),
|
||||||
|
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
|
||||||
|
createCurrentSharedHostAuthOverrideRepository: () => ({
|
||||||
|
findCredentialId: async () => state.hostOverride,
|
||||||
|
}),
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository: () => ({
|
||||||
|
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
|
||||||
|
state.folderLookups.push({ folder, protocol });
|
||||||
|
return state.folderOverride;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getSecretForUser: async () => state.ownerSecret,
|
||||||
|
snapshotForUser: async () => undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolveRecipientSharedHostAuthentication } =
|
||||||
|
await import("../../utils/shared-host-auth-resolver.js");
|
||||||
|
|
||||||
|
const host = (extra: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
id: 42,
|
||||||
|
userId: "owner",
|
||||||
|
folder: "Prod / Web",
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 99,
|
||||||
|
shareSshAuth: false,
|
||||||
|
...extra,
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.hostOverride = null;
|
||||||
|
state.folderOverride = null;
|
||||||
|
state.ownerSecret = null;
|
||||||
|
state.usable = new Set();
|
||||||
|
state.folderLookups = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("folder logins for shared hosts", () => {
|
||||||
|
it("uses the folder's login when the host has none of its own", async () => {
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.usable.add(7);
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
source: "personal-override",
|
||||||
|
credentialId: 7,
|
||||||
|
folder: "Prod",
|
||||||
|
});
|
||||||
|
expect(state.folderLookups).toEqual([
|
||||||
|
{ folder: "Prod / Web", protocol: "ssh" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the host's own login win over the folder's", async () => {
|
||||||
|
state.hostOverride = 5;
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.usable.add(5).add(7);
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ credentialId: 5 });
|
||||||
|
expect(result).not.toHaveProperty("folder");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a folder login the user can no longer use", async () => {
|
||||||
|
state.folderOverride = { folder: "Prod", credentialId: 7 };
|
||||||
|
state.ownerSecret = { authType: "password", password: "x" };
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host({ shareSshAuth: true }),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ source: "owner-shared" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login when nothing is set", async () => {
|
||||||
|
const result = await resolveRecipientSharedHostAuthentication(
|
||||||
|
host(),
|
||||||
|
42,
|
||||||
|
"recipient",
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ source: "required" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,8 +17,14 @@ const state = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||||
findUsableCredential: async (credentialId: number) =>
|
findUsableCredential: async (credentialId: number, userId: string) => {
|
||||||
state.credentials.get(credentialId) ?? null,
|
const credential = state.credentials.get(credentialId);
|
||||||
|
if (!credential) return null;
|
||||||
|
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
|
||||||
|
return credential.userId === userId || sharedWith.includes(userId)
|
||||||
|
? credential
|
||||||
|
: null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
@@ -29,8 +35,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||||
findHostOwnerId: async (hostId: number) =>
|
findHostOwnerId: async (hostId: number) =>
|
||||||
(state.hosts.get(hostId)?.userId as string) ?? null,
|
(state.hosts.get(hostId)?.userId as string) ?? null,
|
||||||
findCredentialByIdForUser: async (credentialId: number) =>
|
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
|
||||||
state.credentials.get(credentialId) ?? null,
|
state.credentials.get(credentialId)?.userId === userId
|
||||||
|
? state.credentials.get(credentialId)
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
createCurrentSharedHostSecretsRepository: () => ({
|
createCurrentSharedHostSecretsRepository: () => ({
|
||||||
upsert: async (row: Record<string, unknown>) => {
|
upsert: async (row: Record<string, unknown>) => {
|
||||||
@@ -260,6 +268,39 @@ describe("SharedHostSecretsManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("snapshots SSH auth from a credential shared with the host owner", async () => {
|
||||||
|
state.credentials.set(123, {
|
||||||
|
id: 123,
|
||||||
|
userId: "cred-owner",
|
||||||
|
sharedWith: ["owner"],
|
||||||
|
username: "cred-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "cred-pass",
|
||||||
|
privateKey: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
});
|
||||||
|
state.hosts.set(
|
||||||
|
42,
|
||||||
|
baseHost({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 123,
|
||||||
|
shareSshAuth: true,
|
||||||
|
password: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.snapshotForUser(7, 42, "target", "owner");
|
||||||
|
|
||||||
|
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
|
||||||
|
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
|
||||||
|
username: "cred-user",
|
||||||
|
authType: "password",
|
||||||
|
password: "cred-pass",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("snapshots a credential-backed protocol login from the credential", async () => {
|
it("snapshots a credential-backed protocol login from the credential", async () => {
|
||||||
state.credentials.set(55, {
|
state.credentials.set(55, {
|
||||||
id: 55,
|
id: 55,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getFriendlyKeyTypeName,
|
getFriendlyKeyTypeName,
|
||||||
validateKeyPair,
|
validateKeyPair,
|
||||||
isSecurityKeyFile,
|
isSecurityKeyFile,
|
||||||
|
clearUnlockedKeyCache,
|
||||||
} from "../../utils/ssh-key-utils.js";
|
} from "../../utils/ssh-key-utils.js";
|
||||||
|
|
||||||
const { parsePrivateKey } = sshpk;
|
const { parsePrivateKey } = sshpk;
|
||||||
@@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("encrypted OpenSSH keys", () => {
|
||||||
|
const key = sshpk.generatePrivateKey("ed25519");
|
||||||
|
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
|
||||||
|
|
||||||
|
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
|
||||||
|
clearUnlockedKeyCache();
|
||||||
|
const first = preparePrivateKeyForSSH2(encrypted, "pw");
|
||||||
|
const parsed = ssh2.utils.parseKey(first);
|
||||||
|
expect(parsed).not.toBeInstanceOf(Error);
|
||||||
|
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
|
||||||
|
key.fingerprint().toString(),
|
||||||
|
);
|
||||||
|
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
|
||||||
|
first.toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the key locked for a wrong or missing passphrase", () => {
|
||||||
|
clearUnlockedKeyCache();
|
||||||
|
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
|
||||||
|
encrypted,
|
||||||
|
);
|
||||||
|
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("security key files", () => {
|
describe("security key files", () => {
|
||||||
const str = (v: string | Buffer) => {
|
const str = (v: string | Buffer) => {
|
||||||
const b = Buffer.from(v);
|
const b = Buffer.from(v);
|
||||||
|
|||||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user