mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9706426421 | ||
|
|
0f949c5e24 | ||
|
|
6f387f1058 | ||
|
|
5582087025 | ||
|
|
363e61961c | ||
|
|
9a8f0ba888 | ||
|
|
0f39ce6369 | ||
|
|
bf67f56c51 | ||
|
|
c129666d7f | ||
|
|
703e8cd037 | ||
|
|
14d4128266 | ||
|
|
50f1882fa9 | ||
|
|
6277d15c2a | ||
|
|
ef4e66659c | ||
|
|
19d4d91eee | ||
|
|
6323459af2 | ||
|
|
6406c3a923 | ||
|
|
f848dee343 | ||
|
|
302ac19e6c | ||
|
|
82143946c7 | ||
|
|
5f55289e00 | ||
|
|
32d77fc6d0 | ||
|
|
0ab7cf2ab8 | ||
|
|
8d0bcb3b1f | ||
|
|
ae9cce4de3 | ||
|
|
c17134a2a4 | ||
|
|
404608867f | ||
|
|
672f5ba80b | ||
|
|
8260af2d57 | ||
|
|
c51c3a9449 | ||
|
|
dc47c4ca86 | ||
|
|
81d79cc89b | ||
|
|
d35458f78b | ||
|
|
f06d540466 | ||
|
|
f3a1087f51 | ||
|
|
69002e6416 | ||
|
|
fafd428072 | ||
|
|
f3e09d4cbd | ||
|
|
cc68fe580f | ||
|
|
488e3f014b | ||
|
|
e5ea61e28a | ||
|
|
f0cb81c3b5 | ||
|
|
2de9bb236b | ||
|
|
30d72554fc | ||
|
|
c4c9b51294 | ||
|
|
b3cc66efdf | ||
|
|
c476ef6b3b | ||
|
|
20eca69d56 | ||
|
|
26757813c8 | ||
|
|
fa0fa7f836 | ||
|
|
ad266956cc | ||
|
|
cad7520c9f | ||
|
|
9ee50d624a | ||
|
|
433ede0b0d | ||
|
|
5e76aec2bf | ||
|
|
f1226b1f1b | ||
|
|
0a9086fb79 | ||
|
|
c42cd40a2c | ||
|
|
780cfb58e2 | ||
|
|
a4b61cc27f | ||
|
|
b5d13c3664 | ||
|
|
fbf267fe5f | ||
|
|
76fd9eedbf | ||
|
|
566b908daf | ||
|
|
d6e8ee4784 | ||
|
|
8af4cbdec4 | ||
|
|
e17b21ff62 | ||
|
|
a15372a224 |
@@ -0,0 +1,76 @@
|
||||
name: Deploy Helm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
namespace:
|
||||
description: "Kubernetes namespace"
|
||||
required: true
|
||||
default: termix
|
||||
release:
|
||||
description: "Helm release name"
|
||||
required: true
|
||||
default: termix
|
||||
values_file:
|
||||
description: "Values file to use"
|
||||
required: true
|
||||
default: charts/termix/values-gitops-example.yaml
|
||||
image_tag:
|
||||
description: "Image tag to deploy"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Configure kubeconfig
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
test -n "$KUBE_CONFIG"
|
||||
echo "$KUBE_CONFIG" | base64 -d > "$RUNNER_TEMP/kubeconfig"
|
||||
chmod 600 "$RUNNER_TEMP/kubeconfig"
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint charts/termix
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
KUBECONFIG: ${{ runner.temp }}/kubeconfig
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
RELEASE_NAME: ${{ inputs.release }}
|
||||
TARGET_NAMESPACE: ${{ inputs.namespace }}
|
||||
VALUES_FILE: ${{ inputs.values_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$RELEASE_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||
[[ "$TARGET_NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||
case "$VALUES_FILE" in
|
||||
charts/termix/*.yaml) ;;
|
||||
*) echo "values_file must be a YAML file under charts/termix" >&2; exit 1 ;;
|
||||
esac
|
||||
test -f "$VALUES_FILE"
|
||||
ARGS=()
|
||||
if [ -n "$IMAGE_TAG" ]; then
|
||||
ARGS+=(--set "image.tag=$IMAGE_TAG")
|
||||
fi
|
||||
helm upgrade --install "$RELEASE_NAME" charts/termix \
|
||||
--namespace "$TARGET_NAMESPACE" \
|
||||
--create-namespace \
|
||||
--values "$VALUES_FILE" \
|
||||
--atomic \
|
||||
--timeout 10m \
|
||||
"${ARGS[@]}"
|
||||
@@ -427,6 +427,7 @@ jobs:
|
||||
npm ci
|
||||
npm install --force @rollup/rollup-darwin-arm64
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
@@ -538,6 +539,14 @@ jobs:
|
||||
fi
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
|
||||
- name: Verify macOS sharp packaging
|
||||
run: |
|
||||
artifacts=(release/termix_macos_*_dmg.dmg)
|
||||
if [ -f release/termix_macos_universal_mas.pkg ]; then
|
||||
artifacts+=(release/termix_macos_universal_mas.pkg)
|
||||
fi
|
||||
node scripts/verify-macos-sharp.cjs "${artifacts[@]}"
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -923,6 +932,9 @@ jobs:
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify macOS sharp packaging
|
||||
run: node scripts/verify-macos-sharp.cjs release_asset/termix_macos_universal_dmg.dmg
|
||||
|
||||
- name: Prepare Homebrew submission files
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
@@ -1013,6 +1025,7 @@ jobs:
|
||||
npm ci
|
||||
npm install --force @rollup/rollup-darwin-arm64
|
||||
npm install dmg-license
|
||||
node scripts/install-macos-sharp.cjs
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
@@ -1154,6 +1167,10 @@ jobs:
|
||||
BUILD_VERSION="${{ steps.build_number.outputs.build_version || github.run_number }}"
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
|
||||
- name: Verify macOS sharp packaging
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
run: node scripts/verify-macos-sharp.cjs release/termix_macos_universal_mas.pkg
|
||||
|
||||
- name: Generate App Store release notes
|
||||
id: asc_notes
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Helm
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "charts/**"
|
||||
- ".github/workflows/helm.yml"
|
||||
- "deploy/**"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "charts/**"
|
||||
- ".github/workflows/helm.yml"
|
||||
- "deploy/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish chart to GHCR OCI registry"
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint charts/termix
|
||||
|
||||
- name: Render default chart
|
||||
run: helm template termix charts/termix --namespace termix
|
||||
|
||||
- name: Render GitOps example
|
||||
run: helm template termix charts/termix --values charts/termix/values-gitops-example.yaml --namespace termix
|
||||
|
||||
- name: Render Traefik example
|
||||
run: helm template termix charts/termix --values charts/termix/values-traefik.yaml --namespace termix
|
||||
|
||||
publish:
|
||||
needs: lint
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Log in to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Package chart
|
||||
run: helm package charts/termix --destination .helm-packages
|
||||
|
||||
- name: Push chart
|
||||
run: |
|
||||
OWNER="$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
|
||||
helm push .helm-packages/termix-*.tgz "oci://ghcr.io/$OWNER/charts"
|
||||
@@ -443,13 +443,21 @@ jobs:
|
||||
|
||||
- name: Create docs release branch
|
||||
working-directory: docs-repo
|
||||
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
|
||||
run: |
|
||||
BRANCH="dev-${{ needs.prep.outputs.version }}"
|
||||
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
echo "Reusing existing docs branch $BRANCH."
|
||||
git checkout -B "$BRANCH" "origin/$BRANCH"
|
||||
else
|
||||
git checkout -B "$BRANCH"
|
||||
fi
|
||||
|
||||
- name: Overwrite OpenAPI spec and regenerate API docs
|
||||
working-directory: docs-repo
|
||||
run: |
|
||||
cp ../termix/openapi.json static/openapi.json
|
||||
npm ci
|
||||
npm run docusaurus clean-api-docs termix
|
||||
npm run docusaurus gen-api-docs termix
|
||||
|
||||
- name: Commit and push docs branch
|
||||
@@ -471,7 +479,7 @@ jobs:
|
||||
|
||||
git add -A
|
||||
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
|
||||
git push --force origin "dev-${{ needs.prep.outputs.version }}"
|
||||
git push origin "dev-${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Open and squash-merge docs PR
|
||||
if: ${{ inputs.mode != 'Dry run' }}
|
||||
@@ -534,7 +542,7 @@ jobs:
|
||||
docs,
|
||||
publish-youtube,
|
||||
]
|
||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' }}
|
||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
include:
|
||||
- local: deploy/gitlab/.gitlab-ci.yml
|
||||
@@ -20,3 +20,6 @@ openapi.json
|
||||
|
||||
# Generated by drizzle-kit; formatting is the tool's own
|
||||
drizzle/
|
||||
|
||||
# Helm templates contain Go template syntax, not plain YAML
|
||||
charts/*/templates/
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "2.6.1"
|
||||
sha256 "716fcbd4ad4aef3dc19f2eb84cee8c46179860eaac180c98cdcadbceca0dacc6"
|
||||
version "2.7.1"
|
||||
sha256 "dbebf8d25b6ae4e2e8aa03b3b20865473234645207d55bd00844a7c7f8d2998c"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -313,6 +313,10 @@ Around 30 languages built in, managed through [Crowdin](https://docs.termix.site
|
||||
|
||||
Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms.
|
||||
|
||||
Deploying to Kubernetes? The Helm chart is in `charts/termix`, and setup instructions
|
||||
covering Ingress, Traefik, Argo CD, GitHub Actions, and GitLab CI are at
|
||||
[docs.termix.site/install/server/kubernetes](https://docs.termix.site/install/server/kubernetes).
|
||||
|
||||
Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features):
|
||||
|
||||
```yaml
|
||||
@@ -327,6 +331,11 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_TUNNEL_HOST: "termix"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
# guacd, not the Termix container, reads and writes redirected-drive files.
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -336,8 +345,10 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
volumes:
|
||||
# The official guacd image runs as a non-root user. Keep the drive path
|
||||
# in this writable shared volume instead of bind-mounting /drive.
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
@@ -350,6 +361,15 @@ networks:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
For multiple Termix backend instances, set the same `REDIS_URL` and optional
|
||||
`TERMIX_REDIS_PREFIX` on every instance. Redis synchronizes collaboration room
|
||||
presence, control requests, controller state, and events. It also routes Step CA
|
||||
OAuth callbacks back to the instance holding the user's terminal; the optional
|
||||
`TERMIX_STEP_CA_REDIS_PREFIX` isolates those short-lived encrypted messages.
|
||||
Keep WebSocket session affinity enabled because live SSH and remote desktop
|
||||
transports remain attached to the backend instance that opened them. A single
|
||||
instance needs no Redis.
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
Termix also has a CLI, so you can manage your servers from a terminal and use Termix in your own scripts.
|
||||
|
||||
+40
-105
@@ -1,6 +1,6 @@
|
||||
<!-- SUMMARY -->
|
||||
|
||||
Termix AI, automations, fleets, workspaces, subhosts, Proxmox metrics, a context aware terminal toolbar, split screen tabs, onboarding, PostgreSQL/MySQL support, and a large batch of fixes.
|
||||
Credential cloning, a WSL local terminal, Helm and GitOps deployment, an editable file manager path bar, download progress bars, and a large batch of connection, sync, and remote desktop fixes.
|
||||
|
||||
<!-- /SUMMARY -->
|
||||
|
||||
@@ -12,114 +12,49 @@ https://youtu.be/lngaePO96tM
|
||||
|
||||
<!-- UPDATE_LOG -->
|
||||
|
||||
- Added completely optional and disabled/removed by default Termix AI, an assistant that can work with your hosts and terminals
|
||||
- This feature was added based off a 60% (yes) to 40% (no) Discord vote.
|
||||
- The assistant cannot change anything on its own. It can only read a limited set of your Termix data and propose actions, and every change or command runs only after you approve it, using your own account and permissions.
|
||||
- It has no access to credentials, SSH keys, vaults, users, roles, sessions, SSO, certificates, audit logs, or instance settings. Secrets are also stripped from anything sent to a model provider.
|
||||
- Both an admin and each user must turn it on before it does anything, and it stays off after upgrading.
|
||||
- Added automations with events, channels, and steps
|
||||
- Added a fleet system with snippets, packages, files, and inventory
|
||||
- Added workspaces to save and restore your tab layout
|
||||
- Added subhosts so hosts can be organized under a parent host
|
||||
- Added Proxmox metrics integration
|
||||
- Added a context aware terminal toolbar with quick links, host info, image pasting, and a movable desktop layout
|
||||
- Added interactive terminal macros
|
||||
- Added first-class split screen tabs
|
||||
- Added a file manager trash instead of permanent deletes
|
||||
- Added a local terminal to the desktop app
|
||||
- Added inheritable connection defaults so hosts can share settings
|
||||
- Added an onboarding flow with an interface simplicity system
|
||||
- Added PostgreSQL and MySQL support alongside SQLite
|
||||
- Added a redesigned host and credential sidebar with synced preferences and drag-to-reorder
|
||||
- Added the option to open some app rail tabs as their own tab or in a right sidebar
|
||||
- Added folder select to host multi select
|
||||
- Added connection logs for RDP, VNC, and Telnet hosts
|
||||
- Added native RDP launching on Windows desktop
|
||||
- Added a drive file browser and drag-and-drop upload for RDP
|
||||
- Added terminal image handoff so images open on your local machine
|
||||
- Added custom terminal font selection
|
||||
- Added trusted proxy authentication
|
||||
- Added global touch input settings
|
||||
- Added adaptive transfers that pick the fastest route and verify integrity
|
||||
- Added adaptive polling and preloading that respond to activity and network cost
|
||||
- Added adaptive SSH local echo for high latency connections
|
||||
- Added custom disk and network metric options
|
||||
- Added the ability to exclude specific mounts from disk usage metrics
|
||||
- Added expanded snippet options
|
||||
- Added downloadable session logs as text files
|
||||
- Added keyboard shortcuts to move between open tabs
|
||||
- Added Discord webhook notification channels
|
||||
- Added paste support when not running over HTTPS
|
||||
- Added Headscale API key and custom API endpoint support
|
||||
- Added a Meta key option for terminals
|
||||
- Added BE-AZERTY keyboard layout for remote desktop
|
||||
- Added PKCE to the OIDC login flow
|
||||
- Added Proxmox VMID and Docker tags to discovered guests
|
||||
- Added custom SSL certificate support in admin settings
|
||||
- Greatly improved performance across metrics polling and host management for large setups
|
||||
- Added the ability to clone an existing credential
|
||||
- Added a WSL option for the local terminal
|
||||
- Added Helm charts and a GitOps deployment setup
|
||||
- Added an editable path bar to the file manager
|
||||
- Added a progress bar for file downloads in the file manager
|
||||
- Added an identity file option so agent authentication stops after the right key
|
||||
- Added an environment variable to turn on silent OIDC login
|
||||
- Added editable model settings for AI providers
|
||||
- Improved tmux monitor performance when aggregating sessions
|
||||
- Improved Linux packaging with standard icon sizes
|
||||
|
||||
<!-- /UPDATE_LOG -->
|
||||
|
||||
<!-- BUG_FIXES -->
|
||||
|
||||
- Periodic SSH terminal stalls caused by SQLite telemetry writes
|
||||
- Missing OPKSSH binary breaking installs without internet access
|
||||
- Session recording writes slowing down terminals
|
||||
- SGR mouse tracking escape codes printing as text
|
||||
- Terminal display distortion with special characters
|
||||
- Windows Ctrl+W not closing the active tab
|
||||
- Tray Quit not terminating the desktop app
|
||||
- Mobile terminal scrollback not matching xterm wheel behavior
|
||||
- tmux breaking on UTF-8 paths
|
||||
- Sudo password auto-fill not persisting
|
||||
- SSH and sudo passwords not being saved or auto-filled
|
||||
- Switching SSH authentication away from Vault failing
|
||||
- Host edits being discarded without a warning
|
||||
- Quick-created credentials not being selected
|
||||
- Saved RDP connection settings not being preserved
|
||||
- RDP domain credentials not being prompted for
|
||||
- Windows key mapping in remote desktop sessions
|
||||
- VNC failing to connect to macOS screen sharing
|
||||
- Mouse input breaking on touch-capable devices in RDP and VNC
|
||||
- Docker runtime selection not persisting, plus Docker manager UI issues
|
||||
- Desktop Docker console WebSocket not being authenticated
|
||||
- Folders intermittently disappearing from duplicate requests
|
||||
- Folder deletion not refreshing the host list
|
||||
- Proxmox guest identity being lost on edit
|
||||
- Long host names shifting dashboard metrics
|
||||
- Host list rows resizing unexpectedly
|
||||
- Metrics collection all firing at once on startup
|
||||
- Session activity writes hitting the database too often
|
||||
- Reachable and available hosts being treated the same
|
||||
- SSH keepalives could not be disabled
|
||||
- OIDC group claims from multiple sources not being merged
|
||||
- OIDC discovery issuers with trailing slashes failing
|
||||
- LDAP logins not using preferred_username
|
||||
- Trusted MFA devices not being bound to a specific client install
|
||||
- 2FA could not be disabled with a single credential
|
||||
- Profile API keys not being shown after creation
|
||||
- SSH agent authentication being unclear in the host editor
|
||||
- Tunnel status stream not requiring authentication
|
||||
- SFTP and Docker console accepting a mismatched host id
|
||||
- SSH connections whose host id resolved elsewhere being accepted
|
||||
- User-managed CA certificates not being applied over SFTP
|
||||
- Already-shared hosts losing their SSH authentication
|
||||
- Real client IP not being captured for SSH login alerts behind a reverse proxy
|
||||
- Audit log IPs not using the real client IP
|
||||
- Homepage System Overview update indicator never firing
|
||||
- Webhook notification channels not working
|
||||
- Remote sync failing behind an nginx proxy
|
||||
- First server sync not refreshing the UI
|
||||
- Desktop app not showing update prompts and hiding the version badge
|
||||
- Desktop Tailscale configuration being lost
|
||||
- Command palette not loading new activity, plus Enter now opens the first result
|
||||
- Database connection failures during login not being reported clearly
|
||||
- audit_logs.user_id not being nullable on fresh SQLite installs
|
||||
- Sync upserts writing to the wrong row
|
||||
- Database migration failures on tables without an id column
|
||||
- ssh_credentials rebuilds not matching the live schema
|
||||
- Sidebar reset and fullscreen buttons sharing the same icon
|
||||
- Host list icons not matching the tab bar icons
|
||||
- Keep Linux credential storage working on unrecognized desktops
|
||||
- Passkey sign in not showing up on the login screen
|
||||
- Connections through jump hosts failing
|
||||
- Jump hosts and remote desktop hosts not resolving after a sync
|
||||
- Malformed websocket messages crashing the server
|
||||
- Encrypted file manager keys not prompting for a passphrase
|
||||
- SSH agent forwarding not working with the in-memory agent
|
||||
- Two factor prompts rejecting codes longer than six digits
|
||||
- OIDC lockout with no way to recover from environment settings
|
||||
- Tailscale requests failing on some setups
|
||||
- Terminal clipboard shortcuts not working on non-QWERTY layouts
|
||||
- Rapid mobile terminal input being sent one keystroke at a time
|
||||
- HTTPS not being able to share the configured port
|
||||
- Portable imports failing on remote databases
|
||||
- Host status not showing when metrics collection is off
|
||||
- Proxmox guest credential usernames being wrong
|
||||
- File drops not working for RDP in the browser
|
||||
- Duplicate Docker HTTPS listener on startup
|
||||
- Remote sync server probe ignoring the certificate setting
|
||||
- Runtime SSL settings not being preserved
|
||||
- Automation notifications missing host details
|
||||
- Connection screens crashing outside the connection log provider
|
||||
- better-sqlite3 failing in Docker on some platforms
|
||||
- Host action rows shifting at large font sizes
|
||||
- Sidebar height jumping when hosts or credentials have tags
|
||||
- Gaps between host rows in the sidebar list
|
||||
- Rounded corners on the host list search bar
|
||||
- Image storage settings text wrapping to one word per line
|
||||
- Unclear wording on the click-to-expand host setting
|
||||
- Dragging a folder into the file manager failing to upload
|
||||
|
||||
<!-- /BUG_FIXES -->
|
||||
|
||||
+18
@@ -3,3 +3,21 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
||||
|
||||
## External secret storage
|
||||
|
||||
By default, a single-container installation generates its keys in the Termix
|
||||
data directory for ease of recovery. Production deployments that keep backups
|
||||
or database files outside a trusted encrypted volume should set
|
||||
`TERMIX_REQUIRE_EXTERNAL_SECRETS=true` and provide all four keys through a
|
||||
secret manager:
|
||||
|
||||
- `JWT_SECRET` (at least 64 characters)
|
||||
- `DATABASE_KEY` (64 hexadecimal characters)
|
||||
- `ENCRYPTION_KEY` (64 hexadecimal characters)
|
||||
- `INTERNAL_AUTH_TOKEN` (at least 32 characters)
|
||||
|
||||
Each value can instead be mounted as a Docker or Kubernetes secret and supplied
|
||||
with its corresponding `_FILE` variable, such as `ENCRYPTION_KEY_FILE`.
|
||||
Hardened mode fails closed instead of writing a replacement key beside the
|
||||
encrypted database.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.git/
|
||||
.github/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tgz
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v2
|
||||
name: termix
|
||||
description: Self-hosted SSH and remote desktop management for Kubernetes.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: 2.7.0
|
||||
home: https://github.com/Termix-SSH/Termix
|
||||
sources:
|
||||
- https://github.com/Termix-SSH/Termix
|
||||
maintainers:
|
||||
- name: Termix maintainers
|
||||
keywords:
|
||||
- ssh
|
||||
- remote-desktop
|
||||
- guacamole
|
||||
- terminal
|
||||
@@ -0,0 +1,17 @@
|
||||
Termix has been installed.
|
||||
|
||||
Service:
|
||||
{{ include "termix.fullname" . }}:{{ .Values.service.port }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Ingress hosts:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- {{ .host }}
|
||||
{{- end }}
|
||||
{{- else if .Values.traefik.ingressRoute.enabled }}
|
||||
Traefik IngressRoute:
|
||||
https://{{ .Values.traefik.ingressRoute.host }}{{ .Values.traefik.ingressRoute.pathPrefix }}
|
||||
{{- else }}
|
||||
Port-forward for local testing:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "termix.fullname" . }} 8080:{{ .Values.service.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{- define "termix.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.labels" -}}
|
||||
helm.sh/chart: {{ include "termix.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{- default (include "termix.fullname" .) .Values.serviceAccount.name -}}
|
||||
{{- else -}}
|
||||
{{- default "default" .Values.serviceAccount.name -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.secretName" -}}
|
||||
{{- default (printf "%s-secret" (include "termix.fullname" .)) .Values.secrets.name -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,148 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
{{- toYaml .Values.strategy | nindent 4 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "termix.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "termix.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: termix
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- range $key, $value := .Values.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.guacd.enabled }}
|
||||
- name: GUACD_HOST
|
||||
value: "127.0.0.1"
|
||||
{{- else if .Values.guacd.host }}
|
||||
- name: GUACD_HOST
|
||||
value: {{ .Values.guacd.host | quote }}
|
||||
{{- end }}
|
||||
- name: GUACD_TUNNEL_HOST
|
||||
value: {{ include "termix.fullname" . | quote }}
|
||||
{{- if ne .Values.database.dialect "sqlite" }}
|
||||
- name: DATABASE_DIALECT
|
||||
value: {{ .Values.database.dialect | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.database.existingSecret.name }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.database.existingSecret.name }}
|
||||
key: {{ .Values.database.existingSecret.urlKey }}
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.create }}
|
||||
{{- range $key, $value := .Values.secrets.data }}
|
||||
{{- if $value }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "termix.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.liveness.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readiness.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
{{- if .Values.guacd.enabled }}
|
||||
- name: guacd
|
||||
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.guacd.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.guacd.securityContext | nindent 12 }}
|
||||
ports:
|
||||
- name: guacd
|
||||
containerPort: {{ .Values.guacd.service.port }}
|
||||
protocol: TCP
|
||||
resources:
|
||||
{{- toYaml .Values.guacd.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /termix-data
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-data" (include "termix.fullname" .)) .Values.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{- if .Values.autoscaling.enabled -}}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "termix.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "termix.fullname" $ }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.networkPolicy.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
{{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
|
||||
egress:
|
||||
{{- toYaml .Values.networkPolicy.egress | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.podDisruptionBudget.enabled -}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}-data
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.persistence.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size | quote }}
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if .Values.secrets.create -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "termix.secretName" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $key, $value := .Values.secrets.data }}
|
||||
{{- if $value }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "termix.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "termix.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{- if .Values.traefik.ingressRoute.enabled -}}
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.traefik.ingressRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
entryPoints:
|
||||
{{- toYaml .Values.traefik.ingressRoute.entryPoints | nindent 4 }}
|
||||
routes:
|
||||
- match: Host(`{{ .Values.traefik.ingressRoute.host }}`) && PathPrefix(`{{ .Values.traefik.ingressRoute.pathPrefix }}`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: {{ include "termix.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.traefik.ingressRoute.middlewares }}
|
||||
middlewares:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.traefik.ingressRoute.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.traefik.ingressRoute.tls.secretName }}
|
||||
secretName: {{ .Values.traefik.ingressRoute.tls.secretName }}
|
||||
{{- end }}
|
||||
{{- if .Values.traefik.ingressRoute.tls.certResolver }}
|
||||
certResolver: {{ .Values.traefik.ingressRoute.tls.certResolver }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{- if and .Values.ingress.enabled .Values.traefik.ingressRoute.enabled -}}
|
||||
{{- fail "ingress and traefik.ingressRoute cannot both be enabled" -}}
|
||||
{{- end -}}
|
||||
{{- if and (eq .Values.database.dialect "sqlite") (or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled) -}}
|
||||
{{- fail "SQLite supports only one replica; use Postgres or MySQL before enabling replicas or autoscaling" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not .Values.guacd.enabled) (not .Values.guacd.host) -}}
|
||||
{{- fail "guacd.host is required when the bundled guacd sidecar is disabled" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.database.existingSecret.name .Values.secrets.create .Values.secrets.data.DATABASE_URL -}}
|
||||
{{- fail "DATABASE_URL must come from either database.existingSecret or secrets.data, not both" -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,28 @@
|
||||
image:
|
||||
repository: ghcr.io/termix-ssh/termix
|
||||
tag: "2.7.0"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: termix.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: termix-tls
|
||||
hosts:
|
||||
- termix.example.com
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
|
||||
secrets:
|
||||
create: false
|
||||
|
||||
database:
|
||||
dialect: sqlite
|
||||
|
||||
extraEnvFrom: []
|
||||
@@ -0,0 +1,32 @@
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
traefik:
|
||||
ingressRoute:
|
||||
enabled: true
|
||||
entryPoints:
|
||||
- websecure
|
||||
host: termix.example.com
|
||||
pathPrefix: /
|
||||
tls:
|
||||
enabled: true
|
||||
certResolver: letsencrypt
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
|
||||
guacd:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,151 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/termix-ssh/termix
|
||||
pullPolicy: IfNotPresent
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
automount: false
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
securityContext: {}
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
annotations: {}
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: termix.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
traefik:
|
||||
ingressRoute:
|
||||
enabled: false
|
||||
entryPoints:
|
||||
- websecure
|
||||
host: termix.example.com
|
||||
pathPrefix: /
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
certResolver: ""
|
||||
middlewares: []
|
||||
annotations: {}
|
||||
|
||||
resources: {}
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
targetCPUUtilizationPercentage: 80
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
annotations: {}
|
||||
existingClaim: ""
|
||||
|
||||
env:
|
||||
PORT: "8080"
|
||||
DATA_DIR: /app/data
|
||||
NODE_ENV: production
|
||||
TERMIX_REQUIRE_EXTERNAL_SECRETS: "false"
|
||||
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
||||
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
||||
|
||||
extraEnv: []
|
||||
extraEnvFrom: []
|
||||
|
||||
secrets:
|
||||
create: false
|
||||
name: ""
|
||||
data:
|
||||
JWT_SECRET: ""
|
||||
DATABASE_KEY: ""
|
||||
ENCRYPTION_KEY: ""
|
||||
INTERNAL_AUTH_TOKEN: ""
|
||||
DATABASE_URL: ""
|
||||
GUACAMOLE_ENCRYPTION_KEY: ""
|
||||
|
||||
database:
|
||||
dialect: sqlite
|
||||
existingSecret:
|
||||
name: ""
|
||||
urlKey: DATABASE_URL
|
||||
|
||||
guacd:
|
||||
enabled: true
|
||||
host: ""
|
||||
image:
|
||||
repository: guacamole/guacd
|
||||
tag: 1.6.0
|
||||
pullPolicy: IfNotPresent
|
||||
resources: {}
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
service:
|
||||
port: 4822
|
||||
annotations: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
readiness:
|
||||
enabled: true
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
ingress: []
|
||||
egress: []
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: termix-traefik
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||
targetRevision: main
|
||||
path: charts/termix
|
||||
helm:
|
||||
releaseName: termix
|
||||
valueFiles:
|
||||
- values-traefik.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: termix
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- PruneLast=true
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: termix
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||
targetRevision: main
|
||||
path: charts/termix
|
||||
helm:
|
||||
releaseName: termix
|
||||
valueFiles:
|
||||
- values-gitops-example.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: termix
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- PruneLast=true
|
||||
@@ -0,0 +1,70 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- package
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
|
||||
HELM_EXPERIMENTAL_OCI: "1"
|
||||
|
||||
helm-lint:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: test
|
||||
script:
|
||||
- helm lint charts/termix
|
||||
- helm template termix charts/termix --values charts/termix/values-gitops-example.yaml >/tmp/termix.yaml
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
docker-build-push:
|
||||
image: docker:27
|
||||
stage: build
|
||||
services:
|
||||
- docker:27-dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- docker build -f docker/Dockerfile -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
|
||||
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
|
||||
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
helm-package-push:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: package
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | helm registry login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- helm dependency update charts/termix
|
||||
- helm package charts/termix --destination .helm-packages
|
||||
- helm push .helm-packages/termix-*.tgz "oci://$CI_REGISTRY_IMAGE/charts"
|
||||
artifacts:
|
||||
paths:
|
||||
- .helm-packages/
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
deploy:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: deploy
|
||||
environment:
|
||||
name: production
|
||||
before_script:
|
||||
- echo "$KUBE_CONFIG" | base64 -d > kubeconfig
|
||||
- chmod 600 kubeconfig
|
||||
script:
|
||||
- helm upgrade --install termix charts/termix --namespace termix --create-namespace --kubeconfig kubeconfig --set image.repository="$CI_REGISTRY_IMAGE" --set image.tag="$IMAGE_TAG" --values charts/termix/values-gitops-example.yaml --atomic --timeout 10m
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
when: manual
|
||||
+17
-9
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-slim AS deps
|
||||
FROM node:26-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -31,30 +31,35 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm rebuild better-sqlite3
|
||||
RUN rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
FROM node:26-slim AS opkssh-downloader
|
||||
ARG TARGETARCH
|
||||
ARG OPKSSH_VERSION=v0.16.0
|
||||
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
|
||||
WORKDIR /opkssh
|
||||
|
||||
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN case "$TARGETARCH" in \
|
||||
amd64) OPKSSH_ARCH=amd64 ;; \
|
||||
arm64) OPKSSH_ARCH=arm64 ;; \
|
||||
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
|
||||
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
|
||||
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
|
||||
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
|
||||
echo "$OPKSSH_SHA256 opkssh-linux-${OPKSSH_ARCH}" | sha256sum -c - && \
|
||||
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
|
||||
echo -n "$OPKSSH_VERSION" > version.txt
|
||||
|
||||
# Stage 5: Production dependencies only
|
||||
FROM node:24-slim AS production-deps
|
||||
FROM node:26-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -67,11 +72,14 @@ COPY scripts/patch-guacamole-lite.cjs ./scripts/
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
node scripts/patch-guacamole-lite.cjs && \
|
||||
npm rebuild better-sqlite3 bcryptjs ssh2 && \
|
||||
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||
npm rebuild bcryptjs ssh2 && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
FROM node:24-slim
|
||||
FROM node:26-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
@@ -101,7 +109,7 @@ COPY --chown=node:node drizzle ./drizzle
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
|
||||
EXPOSE ${PORT}
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
||||
|
||||
@@ -15,6 +15,10 @@ services:
|
||||
GUACD_HOST: "guacd-dev"
|
||||
GUACD_TUNNEL_HOST: "termix-dev"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
# REDIS_URL: "redis://redis:6379"
|
||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||
depends_on:
|
||||
- guacd-dev
|
||||
networks:
|
||||
|
||||
@@ -12,6 +12,19 @@ services:
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_TUNNEL_HOST: "termix"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
# Where guacd stores files for RDP drive redirection (one folder per
|
||||
# user is created underneath). Must be writable by guacd's user.
|
||||
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
|
||||
# Multi-instance deployments should point every backend at the same
|
||||
# Redis. Without it, collaboration rooms keep their single-instance
|
||||
# in-memory fallback.
|
||||
# REDIS_URL: "redis://redis:6379"
|
||||
# TERMIX_REDIS_PREFIX: "termix:collab"
|
||||
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
|
||||
# Hardened deployments can require keys from environment variables or
|
||||
# Docker secrets mounted through JWT_SECRET_FILE, DATABASE_KEY_FILE,
|
||||
# ENCRYPTION_KEY_FILE and INTERNAL_AUTH_TOKEN_FILE.
|
||||
# TERMIX_REQUIRE_EXTERNAL_SECRETS: "true"
|
||||
# Trusted reverse-proxy authentication is disabled by default. When
|
||||
# enabled, do not expose this container directly to untrusted clients.
|
||||
# TRUSTED_PROXY_AUTH_ENABLED: "true"
|
||||
|
||||
+32
-1
@@ -23,6 +23,18 @@ if [ "$(id -u)" = "0" ]; then
|
||||
fi
|
||||
|
||||
DATA_DIR=${DATA_DIR:-/app/data}
|
||||
|
||||
RUNTIME_ENABLE_SSL_SET=${ENABLE_SSL+x}
|
||||
RUNTIME_ENABLE_SSL=${ENABLE_SSL-}
|
||||
RUNTIME_SSL_PORT_SET=${SSL_PORT+x}
|
||||
RUNTIME_SSL_PORT=${SSL_PORT-}
|
||||
RUNTIME_SSL_CERT_PATH_SET=${SSL_CERT_PATH+x}
|
||||
RUNTIME_SSL_CERT_PATH=${SSL_CERT_PATH-}
|
||||
RUNTIME_SSL_KEY_PATH_SET=${SSL_KEY_PATH+x}
|
||||
RUNTIME_SSL_KEY_PATH=${SSL_KEY_PATH-}
|
||||
RUNTIME_SSL_DOMAIN_SET=${SSL_DOMAIN+x}
|
||||
RUNTIME_SSL_DOMAIN=${SSL_DOMAIN-}
|
||||
|
||||
if [ -f "$DATA_DIR/.env" ]; then
|
||||
echo "Loading persisted SSL settings from $DATA_DIR/.env"
|
||||
set -a
|
||||
@@ -30,11 +42,18 @@ if [ -f "$DATA_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
[ "$RUNTIME_ENABLE_SSL_SET" = "x" ] && ENABLE_SSL=$RUNTIME_ENABLE_SSL
|
||||
[ "$RUNTIME_SSL_PORT_SET" = "x" ] && SSL_PORT=$RUNTIME_SSL_PORT
|
||||
[ "$RUNTIME_SSL_CERT_PATH_SET" = "x" ] && SSL_CERT_PATH=$RUNTIME_SSL_CERT_PATH
|
||||
[ "$RUNTIME_SSL_KEY_PATH_SET" = "x" ] && SSL_KEY_PATH=$RUNTIME_SSL_KEY_PATH
|
||||
[ "$RUNTIME_SSL_DOMAIN_SET" = "x" ] && SSL_DOMAIN=$RUNTIME_SSL_DOMAIN
|
||||
|
||||
export PORT=${PORT:-8080}
|
||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
|
||||
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
||||
export TERMIX_SSL_TERMINATED_BY_NGINX=true
|
||||
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
@@ -49,6 +68,11 @@ fi
|
||||
mkdir -p /tmp/nginx
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then
|
||||
echo "HTTP and HTTPS use port $SSL_PORT; disabling the HTTP redirect listener"
|
||||
sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf
|
||||
fi
|
||||
|
||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||
|
||||
@@ -148,7 +172,14 @@ if [ -n "$BASE_PATH" ]; then
|
||||
echo "Injecting BASE_PATH: $BASE_PATH"
|
||||
# Strip trailing slash for use as a path prefix
|
||||
CLEAN_BASE_PATH="${BASE_PATH%/}"
|
||||
find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
|
||||
case "$CLEAN_BASE_PATH" in
|
||||
/*) ;;
|
||||
*) echo "BASE_PATH must start with /" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$CLEAN_BASE_PATH" in
|
||||
*[!A-Za-z0-9_./~-]*) echo "BASE_PATH contains unsupported characters" >&2; exit 1 ;;
|
||||
esac
|
||||
find /app/html -name "index.html" -exec sed -i "s|name=\"termix-base-path\" content=\"\"|name=\"termix-base-path\" content=\"$CLEAN_BASE_PATH\"|g" {} \;
|
||||
# Patch sw.js static asset paths with the base path prefix
|
||||
find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
|
||||
else
|
||||
|
||||
@@ -69,12 +69,14 @@ http {
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# BEGIN HTTP_REDIRECT_SERVER
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name _;
|
||||
|
||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||
}
|
||||
# END HTTP_REDIRECT_SERVER
|
||||
|
||||
server {
|
||||
listen ${SSL_PORT} ssl;
|
||||
@@ -159,6 +161,10 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
|
||||
@@ -142,6 +142,10 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -350,6 +350,13 @@ networks:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
部署多个 Termix 后端实例时,请为所有实例配置相同的 `REDIS_URL`,并可选配置
|
||||
`TERMIX_REDIS_PREFIX`。Redis 会同步协作房间的在线成员、控制请求、控制权和事件,
|
||||
也会把 Step CA OAuth 回调路由回持有用户终端的实例;可通过
|
||||
`TERMIX_STEP_CA_REDIS_PREFIX` 隔离这些短期加密消息。实时 SSH 与远程桌面传输仍
|
||||
依附于创建连接的后端实例,因此负载均衡器需要保持 WebSocket 会话亲和性。
|
||||
单实例部署无需 Redis。
|
||||
|
||||
### 命令行工具
|
||||
|
||||
Termix 还提供命令行工具,你可以在终端里管理服务器,也可以把 Termix 用在自己的脚本中。
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE `collab_room_members` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`room_id` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`room_role` text NOT NULL DEFAULT ('member'),
|
||||
`added_by` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `collab_room_members_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_collab_room_members_room_user` UNIQUE(`room_id`,`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `collab_rooms` (
|
||||
`id` varchar(255) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`owner_user_id` varchar(255) NOT NULL,
|
||||
`persistent` boolean NOT NULL DEFAULT false,
|
||||
`presenter_user_id` varchar(255),
|
||||
`stage_protocol` text,
|
||||
`stage_host_id` int,
|
||||
`stage_share_id` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`ended_at` text,
|
||||
CONSTRAINT `collab_rooms_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_room_id_collab_rooms_id_fk` FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_added_by_users_id_fk` FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_presenter_user_id_users_id_fk` FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_host_id_ssh_data_id_fk` FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_share_id_session_shares_id_fk` FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `collab_rooms` ADD `guest_link_token` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `idx_collab_rooms_guest_token` UNIQUE(`guest_link_token`);
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `secret_sources` (
|
||||
`id` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`kind` text NOT NULL DEFAULT ('onepassword-connect'),
|
||||
`base_url` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`shared` boolean NOT NULL DEFAULT false,
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `secret_sources_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `secret_sources` ADD CONSTRAINT `secret_sources_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
|
||||
@@ -0,0 +1,42 @@
|
||||
CREATE TABLE `credential_access` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`credential_id` int NOT NULL,
|
||||
`user_id` varchar(255),
|
||||
`role_id` int,
|
||||
`granted_by` varchar(255) NOT NULL,
|
||||
`permission_level` text NOT NULL DEFAULT ('use'),
|
||||
`expires_at` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `credential_access_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `shared_credential_secrets` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`credential_access_id` int NOT NULL,
|
||||
`target_user_id` varchar(255) NOT NULL,
|
||||
`credential_id` int NOT NULL,
|
||||
`encrypted_username` text,
|
||||
`auth_type` text NOT NULL DEFAULT ('password'),
|
||||
`encrypted_password` text,
|
||||
`encrypted_key` text,
|
||||
`encrypted_key_password` text,
|
||||
`key_type` text,
|
||||
`public_key` text,
|
||||
`cert_public_key` text,
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `shared_credential_secrets_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_shared_credential_secrets_scope` UNIQUE(`credential_access_id`,`target_user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` ADD CONSTRAINT `credential_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_cred_secrets_access_id_fk` FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_target_user_id_users_id_fk` FOREIGN KEY (`target_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `shared_credential_secrets` ADD CONSTRAINT `shared_credential_secrets_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE `folder_access` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`owner_user_id` varchar(255) NOT NULL,
|
||||
`folder` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255),
|
||||
`role_id` int,
|
||||
`granted_by` varchar(255) NOT NULL,
|
||||
`permission_level` text NOT NULL DEFAULT ('connect'),
|
||||
`expires_at` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `folder_access_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `snippets` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `vault_profiles` MODIFY COLUMN `folder` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_role_id_roles_id_fk` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` ADD CONSTRAINT `folder_access_granted_by_users_id_fk` FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,41 @@
|
||||
"when": 1786757023790,
|
||||
"tag": "0014_bitter_nextwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "5",
|
||||
"when": 1787576997080,
|
||||
"tag": "0015_red_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "5",
|
||||
"when": 1787581983770,
|
||||
"tag": "0016_unusual_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "5",
|
||||
"when": 1787596414390,
|
||||
"tag": "0017_spicy_proteus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "5",
|
||||
"when": 1787600445146,
|
||||
"tag": "0018_fancy_barracuda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "5",
|
||||
"when": 1787602480861,
|
||||
"tag": "0019_certain_archangel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "collab_room_members" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"room_id" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"room_role" text DEFAULT 'member' NOT NULL,
|
||||
"added_by" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "collab_rooms" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_user_id" varchar(255) NOT NULL,
|
||||
"persistent" boolean DEFAULT false NOT NULL,
|
||||
"presenter_user_id" varchar(255),
|
||||
"stage_protocol" text,
|
||||
"stage_host_id" integer,
|
||||
"stage_share_id" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"ended_at" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_room_id_collab_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."collab_rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_presenter_user_id_users_id_fk" FOREIGN KEY ("presenter_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_host_id_ssh_data_id_fk" FOREIGN KEY ("stage_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_share_id_session_shares_id_fk" FOREIGN KEY ("stage_share_id") REFERENCES "public"."session_shares"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_collab_room_members_room_user" ON "collab_room_members" USING btree ("room_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_collab_room_members_user" ON "collab_room_members" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_collab_rooms_owner" ON "collab_rooms" USING btree ("owner_user_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "collab_rooms" ADD COLUMN "guest_link_token" varchar(255);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_collab_rooms_guest_token" ON "collab_rooms" USING btree ("guest_link_token");
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "secret_sources" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"kind" text DEFAULT 'onepassword-connect' NOT NULL,
|
||||
"base_url" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"shared" boolean DEFAULT false NOT NULL,
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "secret_sources" ADD CONSTRAINT "secret_sources_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_secret_sources_user" ON "secret_sources" USING btree ("user_id");
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE "credential_access" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"credential_id" integer NOT NULL,
|
||||
"user_id" varchar(255),
|
||||
"role_id" integer,
|
||||
"granted_by" varchar(255) NOT NULL,
|
||||
"permission_level" text DEFAULT 'use' NOT NULL,
|
||||
"expires_at" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "shared_credential_secrets" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"credential_access_id" integer NOT NULL,
|
||||
"target_user_id" varchar(255) NOT NULL,
|
||||
"credential_id" integer NOT NULL,
|
||||
"encrypted_username" text,
|
||||
"auth_type" text DEFAULT 'password' NOT NULL,
|
||||
"encrypted_password" text,
|
||||
"encrypted_key" text,
|
||||
"encrypted_key_password" text,
|
||||
"key_type" text,
|
||||
"public_key" text,
|
||||
"cert_public_key" text,
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updated_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ADD CONSTRAINT "credential_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_credential_access_id_credential_access_id_fk" FOREIGN KEY ("credential_access_id") REFERENCES "public"."credential_access"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ADD CONSTRAINT "shared_credential_secrets_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 INDEX "idx_credential_access_user_id" ON "credential_access" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_credential_access_role_id" ON "credential_access" USING btree ("role_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_credential_access_credential_id" ON "credential_access" USING btree ("credential_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_shared_credential_secrets_scope" ON "shared_credential_secrets" USING btree ("credential_access_id","target_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_shared_credential_secrets_target" ON "shared_credential_secrets" USING btree ("target_user_id","credential_id");
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE "folder_access" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"owner_user_id" varchar(255) NOT NULL,
|
||||
"folder" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255),
|
||||
"role_id" integer,
|
||||
"granted_by" varchar(255) NOT NULL,
|
||||
"permission_level" text DEFAULT 'connect' NOT NULL,
|
||||
"expires_at" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "snippets" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "vault_profiles" ALTER COLUMN "folder" SET DATA TYPE varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ADD CONSTRAINT "folder_access_granted_by_users_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_folder_access_owner_folder" ON "folder_access" USING btree ("owner_user_id","folder");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,41 @@
|
||||
"when": 1786757021444,
|
||||
"tag": "0014_unusual_maelstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1787576994553,
|
||||
"tag": "0015_early_spitfire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1787581981251,
|
||||
"tag": "0016_slippery_anita_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787596411353,
|
||||
"tag": "0017_curved_pet_avengers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1787600442827,
|
||||
"tag": "0018_puzzling_night_nurse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1787602478567,
|
||||
"tag": "0019_oval_puff_adder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE `collab_room_members` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`room_role` text DEFAULT 'member' NOT NULL,
|
||||
`added_by` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_collab_room_members_room_user` ON `collab_room_members` (`room_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `collab_rooms` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`persistent` integer DEFAULT false NOT NULL,
|
||||
`presenter_user_id` text,
|
||||
`stage_protocol` text,
|
||||
`stage_host_id` integer,
|
||||
`stage_share_id` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`ended_at` text,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `collab_rooms` ADD `guest_link_token` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_collab_rooms_guest_token` ON `collab_rooms` (`guest_link_token`);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `secret_sources` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`kind` text DEFAULT 'onepassword-connect' NOT NULL,
|
||||
`base_url` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`shared` integer DEFAULT false 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_secret_sources_user` ON `secret_sources` (`user_id`);
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TABLE `credential_access` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`credential_id` integer NOT NULL,
|
||||
`user_id` text,
|
||||
`role_id` integer,
|
||||
`granted_by` text NOT NULL,
|
||||
`permission_level` text DEFAULT 'use' NOT NULL,
|
||||
`expires_at` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_user_id` ON `credential_access` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_role_id` ON `credential_access` (`role_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_credential_access_credential_id` ON `credential_access` (`credential_id`);--> statement-breakpoint
|
||||
CREATE TABLE `shared_credential_secrets` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`credential_access_id` integer NOT NULL,
|
||||
`target_user_id` text NOT NULL,
|
||||
`credential_id` integer NOT NULL,
|
||||
`encrypted_username` text,
|
||||
`auth_type` text DEFAULT 'password' NOT NULL,
|
||||
`encrypted_password` text,
|
||||
`encrypted_key` text(16384),
|
||||
`encrypted_key_password` text,
|
||||
`key_type` text,
|
||||
`public_key` text(4096),
|
||||
`cert_public_key` text(8192),
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`credential_access_id`) REFERENCES `credential_access`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`target_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 `idx_shared_credential_secrets_scope` ON `shared_credential_secrets` (`credential_access_id`,`target_user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_shared_credential_secrets_target` ON `shared_credential_secrets` (`target_user_id`,`credential_id`);
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `folder_access` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`folder` text NOT NULL,
|
||||
`user_id` text,
|
||||
`role_id` integer,
|
||||
`granted_by` text NOT NULL,
|
||||
`permission_level` text DEFAULT 'connect' NOT NULL,
|
||||
`expires_at` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`granted_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_folder_access_owner_folder` ON `folder_access` (`owner_user_id`,`folder`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,41 @@
|
||||
"when": 1786757019248,
|
||||
"tag": "0010_mean_queen_noir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1787576992255,
|
||||
"tag": "0011_wakeful_titanium_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1787581978969,
|
||||
"tag": "0012_yellow_firedrake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1787596405780,
|
||||
"tag": "0013_tranquil_master_mold",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1787600440707,
|
||||
"tag": "0014_lean_doctor_octopus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1787602476319,
|
||||
"tag": "0015_glossy_shotgun",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -82,7 +82,7 @@
|
||||
"arch": ["x64", "arm64", "armv7l"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.png",
|
||||
"icon": "public/icons",
|
||||
"category": "Development",
|
||||
"executableName": "termix",
|
||||
"maintainer": "Termix <mail@termix.site>",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function resolveLocalShell(platform, requestedShell, env = process.env) {
|
||||
if (platform === "win32") {
|
||||
if (requestedShell === "wsl") {
|
||||
return { file: "wsl.exe", args: [] };
|
||||
}
|
||||
return {
|
||||
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
||||
args: ["-NoLogo"],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
file:
|
||||
env.TERMIX_LOCAL_SHELL ||
|
||||
env.SHELL ||
|
||||
(platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
|
||||
args: ["-l"],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { resolveLocalShell };
|
||||
+52
-28
@@ -21,7 +21,7 @@ const net = require("net");
|
||||
const tls = require("tls");
|
||||
const zlib = require("zlib");
|
||||
const crypto = require("crypto");
|
||||
const { URL } = require("url");
|
||||
const { URL, pathToFileURL } = require("url");
|
||||
const { fork, spawn } = require("child_process");
|
||||
const pty = require("node-pty");
|
||||
const WebSocket = require("ws");
|
||||
@@ -30,25 +30,10 @@ const { launchNativeRdp } = require("./native-rdp.cjs");
|
||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||
const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
const { resolveLocalShell } = require("./local-shell.cjs");
|
||||
|
||||
const localTerminalSessions = new Map();
|
||||
|
||||
function localShell() {
|
||||
if (process.platform === "win32") {
|
||||
return {
|
||||
file: process.env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
||||
args: ["-NoLogo"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
file:
|
||||
process.env.TERMIX_LOCAL_SHELL ||
|
||||
process.env.SHELL ||
|
||||
(process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
|
||||
args: ["-l"],
|
||||
};
|
||||
}
|
||||
|
||||
function ownedLocalTerminal(event, sessionId) {
|
||||
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
||||
return null;
|
||||
@@ -537,7 +522,11 @@ function httpFetch(url, options = {}) {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
...(isHttps ? getTlsVerificationOptions(url) : {}),
|
||||
...(isHttps
|
||||
? options.allowInvalidCertificate
|
||||
? { rejectUnauthorized: false }
|
||||
: getTlsVerificationOptions(url)
|
||||
: {}),
|
||||
};
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
@@ -547,6 +536,16 @@ function httpFetch(url, options = {}) {
|
||||
// Node's http/https modules never auto-decompress, so an unhandled
|
||||
// content-encoding here silently turns the body into garbage bytes.
|
||||
let stream = res;
|
||||
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
|
||||
let responseBytes = 0;
|
||||
let settled = false;
|
||||
const fail = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stream.destroy();
|
||||
req.destroy();
|
||||
reject(error);
|
||||
};
|
||||
const encoding = (res.headers["content-encoding"] || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
@@ -563,8 +562,17 @@ function httpFetch(url, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on("data", (chunk) => chunks.push(chunk));
|
||||
stream.on("data", (chunk) => {
|
||||
responseBytes += chunk.length;
|
||||
if (responseBytes > maxResponseBytes) {
|
||||
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const data = Buffer.concat(chunks).toString("utf8");
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
@@ -573,7 +581,7 @@ function httpFetch(url, options = {}) {
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("error", fail);
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
@@ -1204,11 +1212,12 @@ function createWindow() {
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
partition: termixSessionPartition,
|
||||
allowRunningInsecureContent: true,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: false,
|
||||
webviewTag: false,
|
||||
offscreen: false,
|
||||
},
|
||||
show: true,
|
||||
@@ -1388,6 +1397,13 @@ function createWindow() {
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
const allowedUrl = isDev
|
||||
? url.startsWith("http://localhost:5173/")
|
||||
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
||||
if (!allowedUrl) event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
@@ -1657,7 +1673,7 @@ ipcMain.handle("clear-remote-sync-config", async () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||
ipcMain.handle("save-remote-sync-jwt", async (_event, token) => {
|
||||
const result = remoteSync.saveRemoteSyncJwt(token);
|
||||
if (result.success) {
|
||||
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
||||
@@ -1665,7 +1681,8 @@ ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
||||
needsReauth: false,
|
||||
lastError: null,
|
||||
});
|
||||
remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
const status = await remoteSync.getRemoteSyncEngine()?.syncNow();
|
||||
return { ...result, status: status || null };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -2928,7 +2945,7 @@ ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
|
||||
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
||||
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
||||
const sessionId = crypto.randomUUID();
|
||||
const shellConfig = localShell();
|
||||
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
||||
const child = pty.spawn(shellConfig.file, shellConfig.args, {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
@@ -3134,7 +3151,11 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
async function testServerConnection(
|
||||
_event,
|
||||
serverUrl,
|
||||
allowInvalidCertificate = false,
|
||||
) {
|
||||
try {
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
@@ -3154,6 +3175,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
const response = await httpFetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
allowInvalidCertificate,
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
@@ -3207,7 +3229,9 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("test-server-connection", testServerConnection);
|
||||
|
||||
function createMenu() {
|
||||
if (process.platform === "darwin") {
|
||||
|
||||
+24
-1
@@ -1,5 +1,28 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const ALLOWED_INVOKE_CHANNELS = new Set([
|
||||
"check-electron-update",
|
||||
"clear-remote-sync-config",
|
||||
"get-desktop-settings",
|
||||
"get-legacy-server-config",
|
||||
"get-remote-sync-config",
|
||||
"get-remote-sync-jwt",
|
||||
"get-remote-sync-status",
|
||||
"get-remote-sync-user-info",
|
||||
"remote-sync-now",
|
||||
"save-desktop-settings",
|
||||
"save-remote-sync-config",
|
||||
"save-remote-sync-jwt",
|
||||
"test-server-connection",
|
||||
]);
|
||||
|
||||
function invokeAllowed(channel, ...args) {
|
||||
if (!ALLOWED_INVOKE_CHANNELS.has(channel)) {
|
||||
return Promise.reject(new Error(`IPC channel is not allowed: ${channel}`));
|
||||
}
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
@@ -103,7 +126,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
},
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
invoke: invokeAllowed,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronClipboard", {
|
||||
|
||||
@@ -37,7 +37,20 @@ function writeJson(filePath, value) {
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
||||
const temporaryPath = `${filePath}.${process.pid}-${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
} catch {
|
||||
// already absent
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getDesktopSettingsPath() {
|
||||
@@ -191,6 +204,7 @@ class RemoteSyncEngine {
|
||||
this.getMainWindow = getMainWindow;
|
||||
this.localJwt = null;
|
||||
this.timer = null;
|
||||
this.startupTimer = null;
|
||||
this.syncing = false;
|
||||
this.status = {
|
||||
connected: false,
|
||||
@@ -220,11 +234,15 @@ class RemoteSyncEngine {
|
||||
const config = getRemoteSyncConfig();
|
||||
this.status.connected = !!config?.serverUrl;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.startupTimer) clearTimeout(this.startupTimer);
|
||||
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
|
||||
if (config?.serverUrl) {
|
||||
// Fire an initial sync shortly after startup rather than waiting a
|
||||
// full interval, but don't block app boot on it.
|
||||
setTimeout(() => this.syncNow(), 5000);
|
||||
this.startupTimer = setTimeout(() => {
|
||||
this.startupTimer = null;
|
||||
this.syncNow();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +251,10 @@ class RemoteSyncEngine {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.startupTimer) {
|
||||
clearTimeout(this.startupTimer);
|
||||
this.startupTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async syncNow() {
|
||||
|
||||
+1
-3
@@ -9,6 +9,7 @@
|
||||
/>
|
||||
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="termix-base-path" content="" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
@@ -68,9 +69,6 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.__TERMIX_BASE_PATH__ = "";
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+389
-279
File diff suppressed because it is too large
Load Diff
+22
-21
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -49,12 +49,12 @@
|
||||
"@anthropic-ai/sdk": "^0.116.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/ldapjs": "^3.0.6",
|
||||
"axios": "^1.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "^6.0.0",
|
||||
"compression": "^1.8.1",
|
||||
@@ -64,33 +64,34 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"jose": "^6.2.8",
|
||||
"jose": "^6.2.9",
|
||||
"js-yaml": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapjs": "^3.0.7",
|
||||
"motion": "^12.43.0",
|
||||
"motion": "^13.1.1",
|
||||
"multer": "^2.2.0",
|
||||
"mysql2": "^3.23.2",
|
||||
"mysql2": "^3.23.4",
|
||||
"nanoid": "^6.0.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"pg": "^8.22.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^6.2.1",
|
||||
"serialport": "^13.0.0",
|
||||
"sharp": "^0.35.3",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^8.10.0",
|
||||
"ws": "^8.21.1"
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.7",
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@codemirror/view": "^6.43.9",
|
||||
"@commitlint/cli": "^21.2.2",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@electron/notarize": "^3.1.1",
|
||||
@@ -119,9 +120,9 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/user-event": "^14.6.5",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -142,8 +143,8 @@
|
||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/ui": "^4.1.11",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
@@ -153,14 +154,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^10.0.4",
|
||||
"cytoscape": "^3.34.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"cytoscape": "^3.34.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"electron": "^43.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"eslint-plugin-react-refresh": "^0.5.4",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^17.8.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
@@ -177,7 +178,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-h5-audio-player": "^3.10.2",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
@@ -185,15 +186,15 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^8.2.0",
|
||||
"vite": "^8.2.2",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const main = readFileSync("electron/main.cjs", "utf8");
|
||||
const preload = readFileSync("electron/preload.js", "utf8");
|
||||
|
||||
describe("Electron security boundary", () => {
|
||||
it("keeps the renderer sandbox and browser security enabled", () => {
|
||||
expect(main).toContain("sandbox: true");
|
||||
expect(main).toContain("webSecurity: true");
|
||||
expect(main).toContain("allowRunningInsecureContent: false");
|
||||
expect(main).toContain("webviewTag: false");
|
||||
});
|
||||
|
||||
it("does not expose an unrestricted IPC invoke primitive", () => {
|
||||
expect(preload).toContain("invoke: invokeAllowed");
|
||||
expect(preload).not.toContain(
|
||||
"invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -171,8 +171,8 @@ function transform(source, dialect) {
|
||||
);
|
||||
|
||||
const imports = isPg
|
||||
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
|
||||
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
|
||||
? `import {\n pgTable,\n text,\n varchar,\n integer,\n serial,\n boolean,\n doublePrecision,\n index,\n uniqueIndex,\n foreignKey,\n type AnyPgColumn,\n} from "drizzle-orm/pg-core";`
|
||||
: `import {\n mysqlTable,\n text,\n varchar,\n int,\n boolean,\n double,\n index,\n uniqueIndex,\n foreignKey,\n type AnyMySqlColumn,\n} from "drizzle-orm/mysql-core";`;
|
||||
|
||||
out = out.replace(
|
||||
/import\s*\{[^}]*\}\s*from\s*"drizzle-orm\/sqlite-core";/,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
function findPackage(start) {
|
||||
let directory = path.dirname(start);
|
||||
while (directory !== path.dirname(directory)) {
|
||||
const manifest = path.join(directory, "package.json");
|
||||
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
|
||||
directory = path.dirname(directory);
|
||||
}
|
||||
throw new Error("Could not locate the installed sharp package manifest");
|
||||
}
|
||||
|
||||
const sharpPackage = findPackage(require.resolve("sharp"));
|
||||
const packages = [
|
||||
"@img/sharp-darwin-arm64",
|
||||
"@img/sharp-darwin-x64",
|
||||
"@img/sharp-libvips-darwin-arm64",
|
||||
"@img/sharp-libvips-darwin-x64",
|
||||
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
|
||||
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
["install", "--force", "--no-save", ...packages],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
@@ -25,11 +25,20 @@ const clientConnectionPath = path.join(
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
const serverPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"Server.js",
|
||||
);
|
||||
|
||||
if (
|
||||
!fs.existsSync(guacdClientPath) ||
|
||||
!fs.existsSync(cryptPath) ||
|
||||
!fs.existsSync(clientConnectionPath)
|
||||
!fs.existsSync(clientConnectionPath) ||
|
||||
!fs.existsSync(serverPath)
|
||||
) {
|
||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||
process.exit(0);
|
||||
@@ -52,6 +61,7 @@ function missingAnchor(patch) {
|
||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||
let serverContent = fs.readFileSync(serverPath, "utf8");
|
||||
|
||||
// Patch 1: protocol version negotiation.
|
||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||
@@ -358,6 +368,27 @@ if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 9: ClientConnection closes malformed-token WebSockets in its
|
||||
// constructor, but Server.newConnection still called connect() afterwards.
|
||||
// That dereferenced the absent connection settings and turned one bad token
|
||||
// into an unhandled rejection that could terminate the backend process.
|
||||
const oldConnectionSetup =
|
||||
" newConnection.on('ready', async (clientConnection) => {";
|
||||
const newConnectionSetup =
|
||||
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
oldConnectionSetup;
|
||||
|
||||
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
|
||||
if (!serverContent.includes(oldConnectionSetup)) {
|
||||
missingAnchor("invalid-token connection guard");
|
||||
}
|
||||
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
@@ -366,6 +397,7 @@ if (!patched) {
|
||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||
fs.writeFileSync(cryptPath, cryptContent);
|
||||
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||
fs.writeFileSync(serverPath, serverContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
||||
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
|
||||
const GuacamoleLite = require("../node_modules/guacamole-lite/lib/Server.js");
|
||||
|
||||
type PatchedGuacdClient = {
|
||||
connectionSettings: Record<string, unknown>;
|
||||
@@ -26,6 +27,50 @@ function createPatchedClient(
|
||||
}
|
||||
|
||||
describe("patch-guacamole-lite", () => {
|
||||
it("rejects a malformed token without starting or retaining a connection", async () => {
|
||||
const server = Object.assign(Object.create(GuacamoleLite.prototype), {
|
||||
connectionsCount: 0,
|
||||
clientOptions: {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: Buffer.alloc(32, 7),
|
||||
},
|
||||
log: {
|
||||
level: 0,
|
||||
stdLog: vi.fn(),
|
||||
errorLog: vi.fn(),
|
||||
},
|
||||
},
|
||||
callbacks: {
|
||||
processConnectionSettings: vi.fn(),
|
||||
},
|
||||
extractGuacdOptions: vi.fn(async () => ({
|
||||
guacdOptions: { host: "127.0.0.1", port: 4822 },
|
||||
connectionInfo: null,
|
||||
isJoin: false,
|
||||
targetSessionId: null,
|
||||
})),
|
||||
activeConnections: new Map(),
|
||||
emit: vi.fn(),
|
||||
});
|
||||
const webSocket = {
|
||||
OPEN: 1,
|
||||
readyState: 1,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
};
|
||||
|
||||
await server.newConnection(webSocket, {
|
||||
url: "/guacamole/websocket/?token=not-an-encrypted-token",
|
||||
});
|
||||
|
||||
expect(webSocket.close).toHaveBeenCalledOnce();
|
||||
expect(server.activeConnections.size).toBe(0);
|
||||
expect(server.emit).not.toHaveBeenCalledWith("open", expect.anything());
|
||||
});
|
||||
|
||||
it("handles guacd dynamic argument requests", () => {
|
||||
const guacdClientPath = path.join(
|
||||
process.cwd(),
|
||||
|
||||
@@ -30,8 +30,11 @@ const patches = [
|
||||
file: "xterm.mjs",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
@@ -46,8 +49,11 @@ const patches = [
|
||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||
],
|
||||
[
|
||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
[
|
||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
@@ -55,8 +61,11 @@ const patches = [
|
||||
file: "xterm.js",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
@@ -71,8 +80,11 @@ const patches = [
|
||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||
],
|
||||
[
|
||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
[
|
||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
@@ -91,12 +103,14 @@ for (const { file, replacements } of patches) {
|
||||
if (source.includes(patched)) {
|
||||
continue;
|
||||
}
|
||||
if (!source.includes(original)) {
|
||||
const originals = Array.isArray(original) ? original : [original];
|
||||
const matched = originals.find((candidate) => source.includes(candidate));
|
||||
if (!matched) {
|
||||
throw new Error(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(original, patched);
|
||||
source = source.replace(matched, patched);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync, spawnSync } = require("node:child_process");
|
||||
|
||||
const architectures = {
|
||||
x64: ["x64"],
|
||||
arm64: ["arm64"],
|
||||
universal: ["x64", "arm64"],
|
||||
};
|
||||
|
||||
function expectedArchitecture(artifact) {
|
||||
const name = path.basename(artifact);
|
||||
if (name.includes("_x64_")) return "x64";
|
||||
if (name.includes("_arm64_")) return "arm64";
|
||||
if (name.includes("_universal_")) return "universal";
|
||||
throw new Error(`Cannot determine architecture from artifact name: ${name}`);
|
||||
}
|
||||
|
||||
function findApp(root) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const candidate = path.join(current, entry.name);
|
||||
if (entry.isDirectory() && entry.name.endsWith(".app")) return candidate;
|
||||
if (entry.isDirectory()) pending.push(candidate);
|
||||
}
|
||||
}
|
||||
throw new Error(`No .app bundle found below ${root}`);
|
||||
}
|
||||
|
||||
function containsFile(root, suffix) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const candidate = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) pending.push(candidate);
|
||||
if (entry.isFile() && entry.name.endsWith(suffix)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function verifyApp(app, architecture, runtimeCheck) {
|
||||
const modules = path.join(
|
||||
app,
|
||||
"Contents/Resources/app.asar.unpacked/node_modules",
|
||||
);
|
||||
|
||||
for (const arch of architectures[architecture]) {
|
||||
for (const [packageName, nativeSuffix] of [
|
||||
[`sharp-darwin-${arch}`, ".node"],
|
||||
[`sharp-libvips-darwin-${arch}`, ".dylib"],
|
||||
]) {
|
||||
const packagePath = path.join(modules, "@img", packageName);
|
||||
if (
|
||||
!fs.existsSync(packagePath) ||
|
||||
!containsFile(packagePath, nativeSuffix)
|
||||
) {
|
||||
throw new Error(
|
||||
`${path.basename(app)} is missing the native binary from @img/${packageName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!runtimeCheck) return;
|
||||
|
||||
const executable = path.join(app, "Contents/MacOS/Termix");
|
||||
const sharpPath = path.join(modules, "sharp");
|
||||
const smoke = [
|
||||
"const sharp = require(process.argv[1]);",
|
||||
"sharp({create:{width:1,height:1,channels:4,background:'#000'}})",
|
||||
".png().toBuffer().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });",
|
||||
].join("");
|
||||
|
||||
for (const arch of architectures[architecture]) {
|
||||
const result = spawnSync(
|
||||
"arch",
|
||||
[
|
||||
arch === "x64" ? "-x86_64" : "-arm64",
|
||||
executable,
|
||||
"-e",
|
||||
smoke,
|
||||
sharpPath,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${path.basename(app)} failed the ${arch} sharp runtime smoke test:\n${result.stderr || result.stdout}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyArtifact(artifact) {
|
||||
const architecture = expectedArchitecture(artifact);
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-"));
|
||||
let mountedAt;
|
||||
|
||||
try {
|
||||
if (artifact.endsWith(".dmg")) {
|
||||
mountedAt = path.join(temporaryRoot, "mounted");
|
||||
fs.mkdirSync(mountedAt);
|
||||
execFileSync("hdiutil", [
|
||||
"attach",
|
||||
artifact,
|
||||
"-readonly",
|
||||
"-nobrowse",
|
||||
"-mountpoint",
|
||||
mountedAt,
|
||||
]);
|
||||
verifyApp(findApp(mountedAt), architecture, true);
|
||||
} else if (artifact.endsWith(".pkg")) {
|
||||
const expanded = path.join(temporaryRoot, "expanded");
|
||||
execFileSync("pkgutil", ["--expand-full", artifact, expanded]);
|
||||
verifyApp(findApp(expanded), architecture, false);
|
||||
} else {
|
||||
throw new Error(`Unsupported macOS artifact: ${artifact}`);
|
||||
}
|
||||
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
||||
} finally {
|
||||
if (mountedAt) {
|
||||
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
|
||||
}
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { expectedArchitecture, verifyApp };
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("macOS sharp artifact verification must run on macOS");
|
||||
}
|
||||
if (process.argv.length < 3) {
|
||||
throw new Error(
|
||||
"Usage: node scripts/verify-macos-sharp.cjs <artifact> [...]",
|
||||
);
|
||||
}
|
||||
|
||||
for (const artifact of process.argv.slice(2))
|
||||
verifyArtifact(path.resolve(artifact));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { expectedArchitecture, verifyApp } = require("./verify-macos-sharp.cjs");
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
function createApp(architectures: string[]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sharp-test-"));
|
||||
temporaryDirectories.push(root);
|
||||
const app = path.join(root, "Termix.app");
|
||||
const modules = path.join(
|
||||
app,
|
||||
"Contents/Resources/app.asar.unpacked/node_modules/@img",
|
||||
);
|
||||
|
||||
for (const architecture of architectures) {
|
||||
const sharp = path.join(modules, `sharp-darwin-${architecture}/lib`);
|
||||
const libvips = path.join(
|
||||
modules,
|
||||
`sharp-libvips-darwin-${architecture}/lib`,
|
||||
);
|
||||
fs.mkdirSync(sharp, { recursive: true });
|
||||
fs.mkdirSync(libvips, { recursive: true });
|
||||
fs.writeFileSync(path.join(sharp, `sharp-darwin-${architecture}.node`), "");
|
||||
fs.writeFileSync(path.join(libvips, "libvips.dylib"), "");
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("macOS sharp artifact verification", () => {
|
||||
it("derives the expected architecture from artifact names", () => {
|
||||
expect(expectedArchitecture("termix_macos_x64_dmg.dmg")).toBe("x64");
|
||||
expect(expectedArchitecture("termix_macos_arm64_dmg.dmg")).toBe("arm64");
|
||||
expect(expectedArchitecture("termix_macos_universal_mas.pkg")).toBe(
|
||||
"universal",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a universal app with both sharp architectures", () => {
|
||||
expect(() =>
|
||||
verifyApp(createApp(["x64", "arm64"]), "universal", false),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an x64 app containing only arm64 sharp binaries", () => {
|
||||
expect(() => verifyApp(createApp(["arm64"]), "x64", false)).toThrow(
|
||||
/sharp-darwin-x64/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
|
||||
/**
|
||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||
* private only if it is "localhost" -- anything else resolves through DNS and
|
||||
* is caught at connect time by the guard instead.
|
||||
* private only if it is "localhost" -- anything else needs DNS resolution.
|
||||
*/
|
||||
export function isPrivateDestination(rawUrl: string): boolean {
|
||||
let url: URL;
|
||||
@@ -98,12 +97,18 @@ export function evaluateEgress(
|
||||
|
||||
const host = normalizeHost(url.hostname);
|
||||
const isPrivate = isPrivateDestination(rawUrl);
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
|
||||
// An explicitly allowlisted hostname may resolve to a private address. It
|
||||
// must use the private fetch path; sending it through safeOutboundFetch
|
||||
// would reject it after DNS resolution and make hostname allowlist entries
|
||||
// ineffective. Only administrators can write this list.
|
||||
if (normalized.includes(host)) {
|
||||
return { allowed: true, isPrivate: true };
|
||||
}
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import express from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import { applyProposal } from "./tools/executor.js";
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
const aiGate = createAiGate();
|
||||
@@ -97,6 +99,7 @@ router.get("/status", authenticateJWT, async (req, res) => {
|
||||
router.get(
|
||||
"/providers",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -147,6 +150,7 @@ router.get(
|
||||
router.post(
|
||||
"/providers",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.manage_providers"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -224,6 +228,7 @@ router.post(
|
||||
router.patch(
|
||||
"/providers/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.manage_providers"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -286,6 +291,7 @@ router.patch(
|
||||
router.delete(
|
||||
"/providers/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.manage_providers"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -358,6 +364,7 @@ router.delete(
|
||||
router.post(
|
||||
"/probe-models",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.manage_providers"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -423,6 +430,7 @@ router.post(
|
||||
router.get(
|
||||
"/providers/:id/models",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -470,6 +478,7 @@ router.get(
|
||||
router.get(
|
||||
"/conversations",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -510,6 +519,7 @@ router.get(
|
||||
router.get(
|
||||
"/conversations/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -560,6 +570,7 @@ router.get(
|
||||
router.delete(
|
||||
"/conversations/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -622,6 +633,7 @@ router.delete(
|
||||
router.post(
|
||||
"/chat/stream",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -840,6 +852,7 @@ router.post(
|
||||
router.post(
|
||||
"/proposals/:id/apply",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.apply_proposals"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
@@ -931,6 +944,7 @@ router.post(
|
||||
router.post(
|
||||
"/proposals/:id/reject",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("ai.use"),
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
||||
import { fetchWithProxy } from "../../utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
@@ -9,8 +9,8 @@ import { AiProviderError } from "./types.js";
|
||||
*
|
||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
||||
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
||||
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
|
||||
* still respecting corporate proxy configuration.
|
||||
* refuse them), so they use the installed Undici fetch implementation with
|
||||
* its matching proxy dispatcher -- still respecting proxy configuration.
|
||||
*/
|
||||
export async function providerFetch(
|
||||
url: string,
|
||||
@@ -24,10 +24,7 @@ export async function providerFetch(
|
||||
}
|
||||
|
||||
if (decision.isPrivate) {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
} as RequestInit);
|
||||
return fetchWithProxy(url, init);
|
||||
}
|
||||
|
||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
||||
|
||||
@@ -5,12 +5,10 @@ import type { AiTool, ToolDefinitionShape } from "./types.js";
|
||||
/**
|
||||
* The allowlist, and the security boundary for the whole feature.
|
||||
*
|
||||
* A model can only ever invoke what appears here. This matters more than usual
|
||||
* in this codebase: PermissionManager.requirePermission exists but is currently
|
||||
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
|
||||
* editor rather than route enforcement. "The assistant cannot reach credentials
|
||||
* or user administration" is therefore a property of this list, not of the
|
||||
* permission system.
|
||||
* A model can only ever invoke what appears here. Route-level RBAC gates the
|
||||
* HTTP API, but tools run in-process with the calling user's identity and never
|
||||
* pass through a router, so "the assistant cannot reach credentials or user
|
||||
* administration" is a property of this list, not of the permission system.
|
||||
*
|
||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||
* SSO or instance settings is deliberately absent and must stay absent.
|
||||
@@ -51,6 +49,8 @@ export const FORBIDDEN_DOMAINS = [
|
||||
"identity",
|
||||
"certificate",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"step_ca",
|
||||
"acme",
|
||||
"ssl",
|
||||
"audit",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
||||
import { executeStep } from "./actions/index.js";
|
||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||
import { compare } from "./conditions.js";
|
||||
@@ -175,8 +176,33 @@ export class AutomationEngine {
|
||||
|
||||
if (!claimed) this.running.add(automation.id);
|
||||
|
||||
const trigger = { ...(request.triggerContext ?? {}) };
|
||||
trigger.type ??= request.triggerType;
|
||||
let host: TemplateContext["host"];
|
||||
if (request.triggerHostId) {
|
||||
try {
|
||||
const resolved = await resolveHostById(
|
||||
request.triggerHostId,
|
||||
automation.userId,
|
||||
);
|
||||
if (resolved) {
|
||||
host = {
|
||||
id: request.triggerHostId,
|
||||
name: resolved.name || resolved.ip,
|
||||
ip: resolved.ip,
|
||||
username: resolved.username,
|
||||
port: resolved.port,
|
||||
};
|
||||
trigger.hostName ??= host.name;
|
||||
}
|
||||
} catch {
|
||||
// A notification should still run with its numeric host id.
|
||||
}
|
||||
}
|
||||
|
||||
const template: TemplateContext = {
|
||||
trigger: request.triggerContext ?? {},
|
||||
host,
|
||||
trigger,
|
||||
steps: {},
|
||||
vars: {},
|
||||
run: {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { readNotificationPrivateAllowlist } from "../utils/notification-egress.js";
|
||||
|
||||
/**
|
||||
* Outbound HTTP for automation steps and notification channels.
|
||||
*
|
||||
* safeOutboundFetch refuses private and loopback addresses, which is the right
|
||||
* default against SSRF but also blocks the self-hosted ntfy or Gotify sitting
|
||||
* on a LAN that many installs actually use. Rather than weaken the guard
|
||||
* globally, a destination can opt in explicitly; everything else about the
|
||||
* guard (scheme, embedded credentials, no redirects) still applies.
|
||||
* on a LAN that many installs actually use. Private delivery therefore needs
|
||||
* both a channel opt-in and an exact host in the administrator allowlist;
|
||||
* scheme validation, DNS pinning and redirect refusal remain in force.
|
||||
*/
|
||||
export interface AutomationFetchOptions {
|
||||
method?: string;
|
||||
@@ -52,8 +53,8 @@ export async function automationFetch(
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-in path. Keeps the parts of the guard that are always right and
|
||||
* drops only the address blocklist.
|
||||
* The opt-in path still goes through the guarded resolver. Only an exact host
|
||||
* authorized by an administrator may resolve to a private address.
|
||||
*/
|
||||
async function privateNetworkFetch(
|
||||
rawUrl: string,
|
||||
@@ -73,5 +74,6 @@ async function privateNetworkFetch(
|
||||
throw new Error("URLs with embedded credentials are not allowed");
|
||||
}
|
||||
|
||||
return fetch(rawUrl, { ...init, redirect: "error" });
|
||||
const allowlist = await readNotificationPrivateAllowlist();
|
||||
return safeOutboundFetch(rawUrl, init, allowlist);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,16 @@ async function sendWebhook(
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: notification.title,
|
||||
hostName:
|
||||
notification.context?.host?.name ??
|
||||
notification.context?.trigger?.hostName,
|
||||
hostId:
|
||||
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
|
||||
ruleName: notification.title,
|
||||
ruleId: notification.context?.run?.automationId,
|
||||
triggerType: notification.context?.trigger?.type,
|
||||
value: notification.context?.trigger?.value,
|
||||
threshold: notification.context?.trigger?.threshold,
|
||||
message: notification.body,
|
||||
severity: notification.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import collabRoutes from "../hosts/collab/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
@@ -29,6 +30,7 @@ import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import aiRoutes from "../ai/index.js";
|
||||
import automationsRoutes from "./routes/automations.js";
|
||||
@@ -73,7 +75,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
app.set("trust proxy", "loopback");
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
@@ -259,9 +261,8 @@ async function fetchGitHubAPI<T>(
|
||||
}
|
||||
}
|
||||
|
||||
app.use(bodyParser.json({ limit: "1gb" }));
|
||||
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
|
||||
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||
app.use(bodyParser.json({ limit: "2mb" }));
|
||||
app.use(bodyParser.urlencoded({ limit: "2mb", extended: true }));
|
||||
app.use(cookieParser());
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
@@ -1753,6 +1754,7 @@ app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/collab", collabRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
@@ -1765,6 +1767,7 @@ app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
// Before the alert routes, which are mounted at the root and would otherwise
|
||||
// have first claim on the path.
|
||||
app.use("/automations", automationsRoutes);
|
||||
@@ -2035,7 +2038,7 @@ httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
});
|
||||
|
||||
export const serverReady = new Promise<void>((resolve) => {
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
httpServer.listen(HTTP_PORT, "127.0.0.1", async () => {
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
@@ -2052,7 +2055,12 @@ if (sslConfig.enabled) {
|
||||
ssl_port: sslConfig.port,
|
||||
backend_http_port: HTTP_PORT,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
sslConfig.enabled &&
|
||||
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
|
||||
) {
|
||||
try {
|
||||
const httpsServer = https.createServer(
|
||||
{
|
||||
@@ -2079,7 +2087,7 @@ if (sslConfig.enabled) {
|
||||
});
|
||||
});
|
||||
|
||||
httpsServer.listen(sslConfig.port, () => {
|
||||
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
|
||||
databaseLogger.success(
|
||||
`Backend is now also listening for HTTPS directly`,
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../../utils/data-dir-guard.js";
|
||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
|
||||
import { SYSTEM_ROLE_DEFAULTS } from "../../utils/permission-catalog.js";
|
||||
import { connectRemoteDatabase } from "./connect.js";
|
||||
import { runRemoteMigrations } from "./migrate.js";
|
||||
import type { PortableDatabase } from "../repositories/database-context.js";
|
||||
@@ -577,6 +578,107 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
persistent INTEGER NOT NULL DEFAULT 0,
|
||||
presenter_user_id TEXT,
|
||||
stage_protocol TEXT,
|
||||
stage_host_id INTEGER,
|
||||
stage_share_id TEXT,
|
||||
guest_link_token TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_room_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
room_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secret_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'onepassword-connect',
|
||||
base_url TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS credential_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_credential_access_user_id ON credential_access (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credential_access_role_id ON credential_access (role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credential_access_credential_id ON credential_access (credential_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared_credential_secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_access_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
credential_id INTEGER NOT NULL,
|
||||
encrypted_username TEXT,
|
||||
auth_type TEXT NOT NULL DEFAULT 'password',
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
key_type TEXT,
|
||||
public_key TEXT,
|
||||
cert_public_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (credential_access_id, target_user_id),
|
||||
FOREIGN KEY (credential_access_id) REFERENCES credential_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_shared_credential_secrets_target ON shared_credential_secrets (target_user_id, credential_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folder_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'connect',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1953,20 +2055,24 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const systemRoles = [
|
||||
{
|
||||
name: "admin",
|
||||
displayName: "rbac.roles.admin",
|
||||
description: "Administrator with full access",
|
||||
permissions: null,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
displayName: "rbac.roles.user",
|
||||
description: "Regular user",
|
||||
permissions: null,
|
||||
},
|
||||
];
|
||||
const systemRoles = Object.entries(SYSTEM_ROLE_DEFAULTS).map(
|
||||
([name, defaults]) => ({
|
||||
name,
|
||||
displayName: `rbac.roles.${name}`,
|
||||
description: defaults.description,
|
||||
permissions: JSON.stringify(defaults.permissions),
|
||||
}),
|
||||
);
|
||||
|
||||
// Route-level RBAC needs the permission lists to exist; roles seeded by
|
||||
// earlier versions carried NULL there. Backfill only NULL so an admin's
|
||||
// edits to these roles are never overwritten.
|
||||
const backfillPermissions = sqlite.prepare(
|
||||
"UPDATE roles SET permissions = ? WHERE name = ? AND is_system = 1 AND permissions IS NULL",
|
||||
);
|
||||
for (const role of systemRoles) {
|
||||
backfillPermissions.run(role.permissions, role.name);
|
||||
}
|
||||
|
||||
for (const role of systemRoles) {
|
||||
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
||||
@@ -2035,6 +2141,10 @@ const migrateSchema = () => {
|
||||
}
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
|
||||
);
|
||||
|
||||
try {
|
||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||
@@ -2986,6 +3096,12 @@ async function initializeRemoteDatabase(
|
||||
await primeCurrentSettingsCache();
|
||||
startSettingsCacheRefresh();
|
||||
|
||||
// The SQLite bootstrap seeds system roles inline below; migrations for the
|
||||
// remote dialects never did, and route-level RBAC denies a user with no
|
||||
// usable role, so they are seeded (and backfilled) here.
|
||||
const { ensureSystemRoles } = await import("../../utils/system-roles.js");
|
||||
await ensureSystemRoles();
|
||||
|
||||
databaseLogger.info(`${dialect} database ready`, {
|
||||
operation: "db_init_complete",
|
||||
dialect,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
double,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
type AnyMySqlColumn,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
@@ -150,7 +151,7 @@ export const hosts = mysqlTable(
|
||||
ip: text("ip").notNull(),
|
||||
port: int("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
// Sub-host nesting: a host acting as an organizational parent for other
|
||||
// hosts, mutually exclusive with folder (see host route validation).
|
||||
parentHostId: int("parent_host_id").references(
|
||||
@@ -439,7 +440,7 @@ export const sshCredentials = mysqlTable(
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
pin: boolean("pin").notNull().default(false),
|
||||
// Manual drag-to-reorder position within a folder. Null means the
|
||||
@@ -505,7 +506,7 @@ export const snippets = mysqlTable(
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
order: int("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
@@ -1022,7 +1023,7 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
@@ -1939,3 +1940,239 @@ export const aiProposals = mysqlTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = mysqlTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: boolean("persistent")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: int("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = mysqlTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
roomId: varchar("room_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = mysqlTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = mysqlTable(
|
||||
"credential_access",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: int("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("use"),
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_credential_access_user_id").on(table.userId),
|
||||
index("idx_credential_access_role_id").on(table.roleId),
|
||||
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||
* recipient, like shared_host_secrets.
|
||||
*/
|
||||
export const sharedCredentialSecrets = mysqlTable(
|
||||
"shared_credential_secrets",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
credentialAccessId: int("credential_access_id").notNull(),
|
||||
targetUserId: varchar("target_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
credentialId: int("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username"),
|
||||
authType: text("auth_type").notNull().default("password"),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key"),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
keyType: text("key_type"),
|
||||
publicKey: text("public_key"),
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.credentialAccessId],
|
||||
foreignColumns: [credentialAccess.id],
|
||||
name: "shared_cred_secrets_access_id_fk",
|
||||
}).onDelete("cascade"),
|
||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||
table.credentialAccessId,
|
||||
table.targetUserId,
|
||||
),
|
||||
index("idx_shared_credential_secrets_target").on(
|
||||
table.targetUserId,
|
||||
table.credentialId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- folder access rules ---
|
||||
|
||||
/**
|
||||
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||
* grants to the hosts in it today; this row is what makes hosts created in
|
||||
* or moved into the folder later inherit the same access.
|
||||
*/
|
||||
export const folderAccess = mysqlTable(
|
||||
"folder_access",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: int("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
doublePrecision,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
type AnyPgColumn,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
@@ -151,7 +152,7 @@ export const hosts = pgTable(
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
// Sub-host nesting: a host acting as an organizational parent for other
|
||||
// hosts, mutually exclusive with folder (see host route validation).
|
||||
parentHostId: integer("parent_host_id").references(
|
||||
@@ -440,7 +441,7 @@ export const sshCredentials = pgTable(
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
pin: boolean("pin").notNull().default(false),
|
||||
// Manual drag-to-reorder position within a folder. Null means the
|
||||
@@ -506,7 +507,7 @@ export const snippets = pgTable(
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
@@ -1023,7 +1024,7 @@ export const vaultProfiles = pgTable("vault_profiles", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
folder: varchar("folder", { length: 255 }),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
@@ -1940,3 +1941,239 @@ export const aiProposals = pgTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = pgTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: boolean("persistent")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = pgTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roomId: varchar("room_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = pgTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: boolean("shared").notNull().default(false),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = pgTable(
|
||||
"credential_access",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("use"),
|
||||
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_credential_access_user_id").on(table.userId),
|
||||
index("idx_credential_access_role_id").on(table.roleId),
|
||||
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||
* recipient, like shared_host_secrets.
|
||||
*/
|
||||
export const sharedCredentialSecrets = pgTable(
|
||||
"shared_credential_secrets",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
credentialAccessId: integer("credential_access_id").notNull(),
|
||||
targetUserId: varchar("target_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username"),
|
||||
authType: text("auth_type").notNull().default("password"),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key"),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
keyType: text("key_type"),
|
||||
publicKey: text("public_key"),
|
||||
certPublicKey: text("cert_public_key"),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.credentialAccessId],
|
||||
foreignColumns: [credentialAccess.id],
|
||||
name: "shared_cred_secrets_access_id_fk",
|
||||
}).onDelete("cascade"),
|
||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||
table.credentialAccessId,
|
||||
table.targetUserId,
|
||||
),
|
||||
index("idx_shared_credential_secrets_target").on(
|
||||
table.targetUserId,
|
||||
table.credentialId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- folder access rules ---
|
||||
|
||||
/**
|
||||
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||
* grants to the hosts in it today; this row is what makes hosts created in
|
||||
* or moved into the folder later inherit the same access.
|
||||
*/
|
||||
export const folderAccess = pgTable(
|
||||
"folder_access",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||
folder: varchar("folder", { length: 255 }).notNull(),
|
||||
|
||||
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: varchar("granted_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||
expiresAt: varchar("expires_at", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
real,
|
||||
index,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
type AnySQLiteColumn,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
@@ -1936,3 +1937,239 @@ export const aiProposals = sqliteTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = sqliteTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: integer("persistent", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: text("presenter_user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: text("guest_link_token"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = sqliteTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
roomId: text("room_id")
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: text("added_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
// --- secret sources ---
|
||||
|
||||
/**
|
||||
* An external password manager Termix pulls secrets from at connect time,
|
||||
* instead of storing them. Only the access token is secret; it is encrypted
|
||||
* with the owner's data key under the row id. Hosts and credentials refer to
|
||||
* entries by reference ("op://vault/item/field") in their secret fields.
|
||||
*/
|
||||
export const secretSources = sqliteTable(
|
||||
"secret_sources",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
// "onepassword-connect" for now; the reference syntax is per kind.
|
||||
kind: text("kind").notNull().default("onepassword-connect"),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
token: text("token").notNull(),
|
||||
// Visible to every user; secrets still decrypt with the owner's key.
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_secret_sources_user").on(table.userId)],
|
||||
);
|
||||
|
||||
// --- credential sharing ---
|
||||
|
||||
/**
|
||||
* Who may use or manage someone else's credential. Same shape as
|
||||
* snippet_access; "use" attaches it to hosts and connects, "manage" also
|
||||
* edits and re-shares it.
|
||||
*/
|
||||
export const credentialAccess = sqliteTable(
|
||||
"credential_access",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("use"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_credential_access_user_id").on(table.userId),
|
||||
index("idx_credential_access_role_id").on(table.roleId),
|
||||
index("idx_credential_access_credential_id").on(table.credentialId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* A recipient's copy of a shared credential's secrets, re-encrypted under
|
||||
* the recipient's data key (the owner's key cannot be used by anyone else).
|
||||
* Rebuilt whenever the owner edits the credential; one row per grant and
|
||||
* recipient, like shared_host_secrets.
|
||||
*/
|
||||
export const sharedCredentialSecrets = sqliteTable(
|
||||
"shared_credential_secrets",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
credentialAccessId: integer("credential_access_id").notNull(),
|
||||
targetUserId: text("target_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
credentialId: integer("credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username"),
|
||||
authType: text("auth_type").notNull().default("password"),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
keyType: text("key_type"),
|
||||
publicKey: text("public_key", { length: 4096 }),
|
||||
certPublicKey: text("cert_public_key", { length: 8192 }),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.credentialAccessId],
|
||||
foreignColumns: [credentialAccess.id],
|
||||
name: "shared_cred_secrets_access_id_fk",
|
||||
}).onDelete("cascade"),
|
||||
uniqueIndex("idx_shared_credential_secrets_scope").on(
|
||||
table.credentialAccessId,
|
||||
table.targetUserId,
|
||||
),
|
||||
index("idx_shared_credential_secrets_target").on(
|
||||
table.targetUserId,
|
||||
table.credentialId,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// --- folder access rules ---
|
||||
|
||||
/**
|
||||
* A standing share on a host folder. Sharing a folder fans out host_access
|
||||
* grants to the hosts in it today; this row is what makes hosts created in
|
||||
* or moved into the folder later inherit the same access.
|
||||
*/
|
||||
export const folderAccess = sqliteTable(
|
||||
"folder_access",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// The folder path as stored on hosts ("Parent / Child"); subfolders inherit.
|
||||
folder: text("folder").notNull(),
|
||||
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permissionLevel: text("permission_level").notNull().default("connect"),
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_folder_access_owner_folder").on(table.ownerUserId, table.folder),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
|
||||
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||
|
||||
export type CollabRoomRole = "host" | "member";
|
||||
|
||||
export interface CollabRoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabRoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export class CollabRoomRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<CollabRoomRecord> {
|
||||
const [created] = await insertReturning(this.context, collabRooms, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId,
|
||||
persistent: input.persistent,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(eq(collabRooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ room: collabRooms })
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
|
||||
.where(
|
||||
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.orderBy(desc(collabRooms.createdAt));
|
||||
return rows.map((row) => row.room);
|
||||
}
|
||||
|
||||
async findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<CollabRoomMemberRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: CollabRoomRole;
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await this.findMember(input.roomId, input.userId)) return false;
|
||||
await this.context.drizzle.insert(collabRoomMembers).values({
|
||||
roomId: input.roomId,
|
||||
userId: input.userId,
|
||||
roomRole: input.roomRole,
|
||||
addedBy: input.addedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
userId: collabRoomMembers.userId,
|
||||
username: users.username,
|
||||
roomRole: collabRoomMembers.roomRole,
|
||||
createdAt: collabRoomMembers.createdAt,
|
||||
})
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
|
||||
.where(eq(collabRoomMembers.roomId, roomId))
|
||||
.orderBy(users.username);
|
||||
}
|
||||
|
||||
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async replaceStage(
|
||||
roomId: string,
|
||||
expectedShareId: string | null,
|
||||
stage: CollabRoomStage,
|
||||
): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRooms.id, roomId),
|
||||
expectedShareId
|
||||
? eq(collabRooms.stageShareId, expectedShareId)
|
||||
: isNull(collabRooms.stageShareId),
|
||||
),
|
||||
);
|
||||
const changed = rowsAffected(result) > 0;
|
||||
if (changed) await this.afterWrite();
|
||||
return changed;
|
||||
}
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
return this.updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({
|
||||
endedAt: new Date().toISOString(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRooms)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user