release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Opens a host's web UI in a tab or a separate desktop window, either directly or over an SSH tunnel.
+38
View File
@@ -0,0 +1,38 @@
# Web Endpoint
Open a host's web UI in a tab or a separate desktop window, directly or over an SSH tunnel.
## Features
- Open a host's web UI in a tab or a separate desktop window.
- Connect directly, or over an SSH tunnel when the web UI is only reachable from the host.
- Up to 16 endpoints per host.
## Setup
Each endpoint is one of two kinds:
- Direct: your browser loads `scheme://host:port/path` itself.
- Tunnel: Termix opens an SSH tunnel to the port and loads it from there. Unused tunnels close after ten minutes.
Tunnel endpoints need the Tunnels plugin.
## Settings
### Host
- Enable web endpoints and the list of endpoints for this host.
## Services
Uses from other plugins:
- `tunnels.access` to open tunnels. Required.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+55
View File
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Geen gasheer gekies nie",
"notFound": "Hierdie eindpunt bestaan nie meer nie.",
"tunnelUnreachableFromBrowser": "Hierdie eindpunt tonnel na 127.0.0.1 op die masjien wat Termix gebruik, wat jou blaaier nie kan bereik nie. Stel die eindpunt se Bind Host na 'n adres waarop die masjien antwoord, soos 0.0.0.0, en gee dit 'n vaste Lokale Poort.",
"tunnelSharesSessionCookie": "As jy hierdie tonnel oopmaak, sal jou Termix-sessie na die getunnelde diens gestuur word, want jou blaaier sal dit bereik by dieselfde gasheernaam waarvandaan Termix bedien word. Koekies ignoreer die poort, dus kan die twee nie van mekaar onderskei word nie. Gebruik die Termix-lessenaar-app, of maak Termix oop by localhost.",
"directSharesSessionCookie": "As jy hierdie eindpunt oopmaak, sal jou Termix-sessie daarnatoe gestuur word, want jou blaaier bereik dit op dieselfde webwerf waarvandaan Termix bedien word. Koekies ignoreer die poort, dus is 'n ander poort nie 'n ander identiteit nie. Gebruik die Termix-lessenaartoepassing of bedien hierdie web-UI vanaf 'n ander gasheernaam.",
"retry": "Probeer weer",
"reload": "Herlaai",
"copyUrl": "Kopieer URL",
"urlCopied": "URL gekopieer",
"copyFailed": "Kon nie URL kopieer nie",
"openExternally": "Maak ekstern oop",
"isolationUnavailable": "Hierdie blaaier kan nie web-eindpunte van jou Termix-sessie isoleer nie. Gebruik 'n blaaier met ondersteuning vir iframes sonder geloofsbriewe.",
"isolationNotice": "Ingeslote bladsye gebruik 'n geïsoleerde sessie. Vir bladsye wat berging of opspring-aanmelding benodig, gebruik 'n geïsoleerde venster in die rekenaarprogram.",
"openIsolatedWindow": "Maak geïsoleerde venster oop"
},
"hosts": {
"tabWebUi": "Web-UI",
"webUiEndpointLabel": "Eindpunt {{number}}",
"webUiIntegration": "Web-UI",
"webUiEndpointsSection": "Eindpunte",
"webUiNoEndpoints": "Geen web-eindpunte nog nie. Gebruik Voeg eindpunt by om een te skep.",
"webUiEndpointsDisabled": "Skakel Web UI hierbo aan om eindpunte by te voeg.",
"webUiRequirementsText": "'n Direkte eindpunt word deur jou blaaier oopgemaak, dus moet die poort daarvan van hier af bereikbaar wees. 'n SSH-tonnel word eerder deur Termix oor hierdie gasheer se SSH-verbinding oopgemaak, dus hoef die poort slegs vanaf die gasheer bereikbaar te wees.",
"enableWebUi": "Aktiveer web-eindpunte",
"enableWebUiDesc": "Maak hierdie gasheer se webkoppelvlakke vanaf die sybalk oop.",
"webUiAddEndpoint": "Voeg eindpunt by",
"webUiAddress": "Adres",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Waar die agterkant die voorwaartse kant bind. Los leeg vir 127.0.0.1, wat slegs die masjien wat Termix gebruik, kan bereik.",
"webUiBindHostExposed": "Hierdie adres is bereikbaar buite die Termix-masjien, so enigiemand wat die poort kan bereik, kry hierdie web-UI sonder om aan te meld.",
"webUiLocalPort": "Plaaslike Hawe",
"webUiLocalPortAuto": "Outomaties",
"webUiLocalPortDesc": "Na watter poort die aanstuurder luister. Los leeg om die stelsel toe te laat om te kies, of stel een wanneer die poort vooraf bekend moet wees, soos 'n houer wat dit publiseer.",
"webUiNewEndpointLabel": "Nuwe eindpunt",
"webUiLabel": "Etiket",
"webUiScheme": "Skema",
"webUiPort": "Hawe",
"webUiPath": "Pad",
"webUiAccess": "Toegang",
"webUiAccessDirect": "Direkte",
"webUiAccessTunnel": "SSH-tonnel",
"webUiAccessTunnelUnavailable": "'n SSH-tonnel benodig SSH geaktiveer op hierdie gasheer en 'n verbindingsoorsprong van hierdie toestel.",
"webUiRender": "Maak oop in",
"webUiRenderExternal": "Geïsoleerde lessenaarvenster",
"webUiRenderEmbedded": "Termix-oortjie",
"webUiRenderEmbeddedDesc": "Inbedding is die beste poging: sommige webkoppelvlakke weier om in 'n raam te laai, en 'n aanmelding wat op dieselfde-webwerf-koekies staatmaak, kan 'n lus vorm. Die eksterne blaaier werk altyd.",
"webUiIgnoreCert": "Laat ongeldige sertifikaat toe",
"webUiIgnoreCertDesc": "Slegs direkte eindpunte. Teruglus is reeds vrygestel, dus het dit geen effek op tonneleindpunte nie.",
"webUiRemoveEndpoint": "Verwyder eindpunt",
"webUiOpenFailed": "Kon nie die web-eindpunt oopmaak nie",
"bindHost": "Bind gasheer"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "لم يتم تحديد أي جهاز مضيف",
"notFound": "لم تعد هذه النقطة النهائية موجودة.",
"tunnelUnreachableFromBrowser": "تتصل هذه النقطة الطرفية بالعنوان 127.0.0.1 على الجهاز الذي يُشغّل Termix، وهو عنوان لا يمكن لمتصفحك الوصول إليه. عيّن عنوان \"Bind Host\" لهذه النقطة الطرفية إلى عنوان يستجيب له الجهاز، مثل 0.0.0.0، وحدّد لها منفذًا محليًا ثابتًا.",
"tunnelSharesSessionCookie": "سيؤدي فتح هذا النفق إلى توجيه جلسة Termix الخاصة بك إلى الخدمة المُمرَّرة عبر النفق، لأن متصفحك سيصل إليها عبر نفس اسم المضيف الذي يُقدَّم منه Termix. تتجاهل ملفات تعريف الارتباط رقم المنفذ، لذا لا يمكن التمييز بينهما. استخدم تطبيق Termix لسطح المكتب، أو افتح Termix على localhost.",
"directSharesSessionCookie": "سيؤدي فتح هذه النقطة النهائية إلى توجيه جلسة Termix الخاصة بك إليها، لأن متصفحك يصل إليها من نفس الموقع الذي يُقدّم منه Termix. تتجاهل ملفات تعريف الارتباط رقم المنفذ، لذا فإن استخدام منفذ مختلف لا يعني تغيير الهوية. استخدم تطبيق Termix لسطح المكتب، أو قم بتشغيل واجهة المستخدم الرسومية هذه من اسم مضيف مختلف.",
"retry": "إعادة المحاولة",
"reload": "إعادة تحميل",
"copyUrl": "انسخ الرابط",
"urlCopied": "تم نسخ الرابط",
"copyFailed": "فشل نسخ عنوان URL",
"openExternally": "فتح خارجيًا",
"isolationUnavailable": "لا يستطيع هذا المتصفح عزل نقاط نهاية الويب عن جلسة Termix الخاصة بك. استخدم متصفحًا يدعم الإطارات المضمنة بدون بيانات اعتماد.",
"isolationNotice": "تستخدم الصفحات المضمنة جلسة معزولة. أما بالنسبة للصفحات التي تتطلب تخزينًا أو تسجيل دخول منبثق، فاستخدم نافذة معزولة في تطبيق سطح المكتب.",
"openIsolatedWindow": "نافذة مفتوحة معزولة"
},
"hosts": {
"tabWebUi": "واجهة المستخدم على الويب",
"webUiEndpointLabel": "نقطة النهاية {{number}}",
"webUiIntegration": "واجهة المستخدم على الويب",
"webUiEndpointsSection": "نقاط النهاية",
"webUiNoEndpoints": "لا توجد نقاط نهاية ويب حتى الآن. استخدم \"إضافة نقطة نهاية\" لإنشاء واحدة.",
"webUiEndpointsDisabled": "قم بتشغيل واجهة المستخدم على الويب أعلاه لإضافة نقاط النهاية.",
"webUiRequirementsText": "يفتح متصفحك نقطة نهاية مباشرة، لذا يجب أن يكون منفذها قابلاً للوصول من هنا. أما Termix فيفتح نفق SSH عبر اتصال SSH الخاص بهذا المضيف، لذا يكفي أن يكون المنفذ قابلاً للوصول من المضيف نفسه.",
"enableWebUi": "تفعيل نقاط نهاية الويب",
"enableWebUiDesc": "افتح واجهات الويب الخاصة بهذا المضيف من الشريط الجانبي.",
"webUiAddEndpoint": "إضافة نقطة نهاية",
"webUiAddress": "العنوان",
"webUiOpening": "افتتاح",
"webUiBindHostDesc": "حيث يقوم الخادم الخلفي بربط التوجيه الأمامي. اترك هذا الحقل فارغًا للعنوان 127.0.0.1، والذي لا يمكن الوصول إليه إلا من خلال الجهاز الذي يُشغّل برنامج Termix.",
"webUiBindHostExposed": "يمكن الوصول إلى هذا العنوان من خارج جهاز Termix، لذا فإن أي شخص يمكنه الوصول إلى المنفذ سيحصل على واجهة المستخدم هذه عبر الويب دون الحاجة إلى تسجيل الدخول.",
"webUiLocalPort": "المنفذ المحلي",
"webUiLocalPortAuto": "معادلة",
"webUiLocalPortDesc": "ما هو المنفذ الذي يستمع إليه التوجيه؟ اتركه فارغًا ليختاره النظام، أو حدد واحدًا عندما يكون من الضروري معرفة المنفذ مسبقًا، كما هو الحال في حاوية تنشره.",
"webUiNewEndpointLabel": "نقطة نهاية جديدة",
"webUiLabel": "تسمية",
"webUiScheme": "مخطط",
"webUiPort": "منفذ",
"webUiPath": "المسار",
"webUiAccess": "وصول",
"webUiAccessDirect": "مباشر",
"webUiAccessTunnel": "نفق SSH",
"webUiAccessTunnelUnavailable": "يتطلب نفق SSH تمكين SSH على هذا المضيف ومصدر اتصال من هذا الجهاز.",
"webUiRender": "افتح في",
"webUiRenderExternal": "نافذة سطح المكتب المعزولة",
"webUiRenderEmbedded": "علامة تبويب Termix",
"webUiRenderEmbeddedDesc": "يُعدّ تضمين المحتوى أفضل محاولة ممكنة: فبعض واجهات الويب ترفض التحميل داخل إطار، وقد تتكرر عملية تسجيل الدخول التي تعتمد على ملفات تعريف الارتباط الخاصة بالموقع نفسه. أما المتصفح الخارجي فيعمل دائمًا.",
"webUiIgnoreCert": "السماح بشهادة غير صالحة",
"webUiIgnoreCertDesc": "نقاط النهاية المباشرة فقط. تم إعفاء نقاط نهاية Loopback بالفعل، لذا لن يكون لهذا أي تأثير على نقاط نهاية النفق.",
"webUiRemoveEndpoint": "إزالة نقطة النهاية",
"webUiOpenFailed": "فشل فتح نقطة نهاية الويب",
"bindHost": "مضيف الربط"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Няма избран хост",
"notFound": "Тази крайна точка вече не съществува.",
"tunnelUnreachableFromBrowser": "Тази крайна точка се свързва с 127.0.0.1 на машината, на която работи Termix, до която вашият браузър не може да достигне. Задайте Bind Host на крайната точка на адрес, на който машината отговаря, например 0.0.0.0, и му дайте фиксиран локален порт.",
"tunnelSharesSessionCookie": "Отварянето на този тунел ще изпрати вашата Termix сесия към тунелираната услуга, защото вашият браузър ще я достигне на същото име на хост, от което се обслужва Termix. „Бисквитките“ игнорират порта, така че двете не могат да бъдат разграничени. Използвайте настолното приложение Termix или отворете Termix на localhost.",
"directSharesSessionCookie": "Отварянето на тази крайна точка ще изпрати вашата Termix сесия към нея, защото вашият браузър достига до нея на същия сайт, от който се обслужва Termix. „Бисквитките“ игнорират порта, така че различен порт не е различна идентичност. Използвайте настолното приложение Termix или обслужвайте този уеб потребителски интерфейс от различно име на хост.",
"retry": "Опитай отново",
"reload": "Презареждане",
"copyUrl": "Копиране на URL адрес",
"urlCopied": "URL адресът е копиран",
"copyFailed": "Копирането на URL адреса не бе успешно",
"openExternally": "Отворено външно",
"isolationUnavailable": "Този браузър не може да изолира уеб крайни точки от вашата Termix сесия. Използвайте браузър с поддръжка на iframe без идентификационни данни.",
"isolationNotice": "Вградените страници използват изолирана сесия. За страници, изискващи място за съхранение или изскачащ вход, използвайте изолиран прозорец в настолното приложение.",
"openIsolatedWindow": "Отворен изолиран прозорец"
},
"hosts": {
"tabWebUi": "Уеб потребителски интерфейс",
"webUiEndpointLabel": "Крайна точка {{number}}",
"webUiIntegration": "Уеб потребителски интерфейс",
"webUiEndpointsSection": "Крайни точки",
"webUiNoEndpoints": "Все още няма уеб крайни точки. Използвайте „Добавяне на крайна точка“, за да създадете такава.",
"webUiEndpointsDisabled": "Включете уеб потребителския интерфейс по-горе, за да добавите крайни точки.",
"webUiRequirementsText": "Директна крайна точка се отваря от вашия браузър, така че портът ѝ трябва да е достъпен оттук. Вместо това Termix отваря SSH тунел през SSH връзката на този хост, така че портът трябва да е достъпен само от хоста.",
"enableWebUi": "Активиране на уеб крайни точки",
"enableWebUiDesc": "Отворете уеб интерфейсите на този хост от страничната лента.",
"webUiAddEndpoint": "Добавяне на крайна точка",
"webUiAddress": "Адрес",
"webUiOpening": "Откриване",
"webUiBindHostDesc": "Където бекендът свързва пренасочващия сървър. Оставете празно за 127.0.0.1, до който може да достигне само машината, на която работи Termix.",
"webUiBindHostExposed": "Този адрес е достъпен отвъд машината Termix, така че всеки, който може да достигне порта, получава този уеб интерфейс без вход.",
"webUiLocalPort": "Локален порт",
"webUiLocalPortAuto": "Автоматично",
"webUiLocalPortDesc": "На кой порт слуша пренасочването. Оставете празно, за да позволите на системата да избере, или задайте порт, когато портът трябва да бъде известен предварително, например контейнер, който го публикува.",
"webUiNewEndpointLabel": "Нова крайна точка",
"webUiLabel": "Етикет",
"webUiScheme": "Схема",
"webUiPort": "Порт",
"webUiPath": "Път",
"webUiAccess": "Достъп",
"webUiAccessDirect": "Директно",
"webUiAccessTunnel": "SSH тунел",
"webUiAccessTunnelUnavailable": "SSH тунелът изисква SSH да е активиран на този хост и произход на връзката на това устройство.",
"webUiRender": "Отвори в",
"webUiRenderExternal": "Изолиран прозорец на работния плот",
"webUiRenderEmbedded": "раздел Термикс",
"webUiRenderEmbeddedDesc": "Вграждането е най-добрият начин: някои уеб интерфейси отказват да се заредят в кадър и влизане, което разчита на „бисквитки“ на същия сайт, може да се зацикли. Външният браузър винаги работи.",
"webUiIgnoreCert": "Разрешаване на невалиден сертификат",
"webUiIgnoreCertDesc": "Само директни крайни точки. Loopback вече е изключен, така че това няма ефект върху крайните точки на тунела.",
"webUiRemoveEndpoint": "Премахване на крайната точка",
"webUiOpenFailed": "Неуспешно отваряне на уеб крайната точка",
"bindHost": "Свързване на хост"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"notFound": "Aquest punt final ja no existeix.",
"tunnelUnreachableFromBrowser": "Aquest punt final connecta a 127.0.0.1 a la màquina que executa Termix, a la qual el navegador no pot arribar. Establiu l'amfitrió d'enllaç del punt final a una adreça a la qual la màquina respongui, com ara 0.0.0.0, i doneu-li un port local fix.",
"tunnelSharesSessionCookie": "Si obriu aquest túnel, la vostra sessió de Termix s'enviaria al servei tunelitzat, perquè el vostre navegador hi arribaria amb el mateix nom d'amfitrió des del qual es serveix Termix. Les galetes ignoren el port, de manera que no es poden distingir. Feu servir l'aplicació d'escriptori de Termix o obriu Termix a localhost.",
"directSharesSessionCookie": "Si obriu aquest punt final, la vostra sessió de Termix s'hi enviaria, perquè el vostre navegador hi arriba al mateix lloc des d'on es serveix Termix. Les galetes ignoren el port, de manera que un port diferent no és una identitat diferent. Feu servir l'aplicació d'escriptori de Termix o serviu aquesta interfície d'usuari web des d'un nom d'amfitrió diferent.",
"retry": "Torna-ho a intentar",
"reload": "Torna a carregar",
"copyUrl": "Copia l'URL",
"urlCopied": "URL copiada",
"copyFailed": "No s'ha pogut copiar l'URL",
"openExternally": "Obre externament",
"isolationUnavailable": "Aquest navegador no pot aïllar els punts finals web de la vostra sessió de Termix. Feu servir un navegador que admeti iframes sense credencials.",
"isolationNotice": "Les pàgines incrustades utilitzen una sessió aïllada. Per a les pàgines que requereixen emmagatzematge o inici de sessió emergent, utilitzeu una finestra aïllada a l'aplicació d'escriptori.",
"openIsolatedWindow": "Finestra aïllada oberta"
},
"hosts": {
"tabWebUi": "Interfície d'usuari web",
"webUiEndpointLabel": "Punt final {{number}}",
"webUiIntegration": "Interfície d'usuari web",
"webUiEndpointsSection": "Punts finals",
"webUiNoEndpoints": "Encara no hi ha cap punt final web. Fes servir Afegeix punt final per crear-ne un.",
"webUiEndpointsDisabled": "Activeu la interfície web que hi ha a sobre per afegir punts finals.",
"webUiRequirementsText": "El navegador obre un punt final directe, de manera que el seu port ha de ser accessible des d'aquí. Termix obre un túnel SSH a través de la connexió SSH d'aquest host, de manera que el port només ha de ser accessible des de l'host.",
"enableWebUi": "Habilita els punts finals web",
"enableWebUiDesc": "Obriu les interfícies web d'aquest amfitrió des de la barra lateral.",
"webUiAddEndpoint": "Afegeix un punt final",
"webUiAddress": "Adreça",
"webUiOpening": "Obertura",
"webUiBindHostDesc": "On el backend enllaça amb el forward. Deixeu en blanc per a 127.0.0.1, que només pot assolir la màquina que executa Termix.",
"webUiBindHostExposed": "Aquesta adreça és accessible més enllà de la màquina Termix, de manera que qualsevol persona que pugui accedir al port obté aquesta interfície web sense necessitat d'iniciar sessió.",
"webUiLocalPort": "Port local",
"webUiLocalPortAuto": "Automàtic",
"webUiLocalPortDesc": "Port on escolta el reenviament. Deixeu-ho en blanc perquè el sistema el seleccioni o definiu-ne un quan el port s'hagi de conèixer per endavant, com ara un contenidor que el publica.",
"webUiNewEndpointLabel": "Nou punt final",
"webUiLabel": "Etiqueta",
"webUiScheme": "Esquema",
"webUiPort": "Port",
"webUiPath": "Camí",
"webUiAccess": "Accés",
"webUiAccessDirect": "Directe",
"webUiAccessTunnel": "Túnel SSH",
"webUiAccessTunnelUnavailable": "Un túnel SSH necessita SSH habilitat en aquest host i un origen de connexió d'aquest dispositiu.",
"webUiRender": "Obre en",
"webUiRenderExternal": "Finestra d'escriptori aïllada",
"webUiRenderEmbedded": "Pestanya Termix",
"webUiRenderEmbeddedDesc": "La incrustació és el millor esforç: algunes interfícies web es neguen a carregar-se en un marc i un inici de sessió que depèn de les galetes del mateix lloc pot repetir-se. El navegador extern sempre funciona.",
"webUiIgnoreCert": "Permetre certificat no vàlid",
"webUiIgnoreCertDesc": "Només punts finals directes. El bucle ja està exempt, per tant, això no té cap efecte sobre els punts finals del túnel.",
"webUiRemoveEndpoint": "Elimina el punt final",
"webUiOpenFailed": "No s'ha pogut obrir el punt final web.",
"bindHost": "Vincula l'amfitrió"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nebyl vybrán žádný hostitel",
"notFound": "Tento koncový bod již neexistuje.",
"tunnelUnreachableFromBrowser": "Tento koncový bod se tunelově připojuje k adrese 127.0.0.1 na počítači se spuštěným programem Termix, ke které se váš prohlížeč nemůže dostat. Nastavte Bind Host koncového bodu na adresu, na které počítač odpovídá, například 0.0.0.0, a přidělte mu pevný lokální port.",
"tunnelSharesSessionCookie": "Otevřením tohoto tunelu by se vaše relace Termixu odeslala na tunelovanou službu, protože by se k ní váš prohlížeč dostal na stejném hostitelském názvu, ze kterého je Termix obsluhován. Soubory cookie ignorují port, takže tyto dva servery nelze od sebe rozlišit. Použijte desktopovou aplikaci Termix nebo otevřete Termix na localhostu.",
"directSharesSessionCookie": "Otevřením tohoto koncového bodu by se vaše relace Termixu odeslala na něj, protože váš prohlížeč se k němu připojuje na stejném webu, ze kterého je Termix obsluhován. Soubory cookie port ignorují, takže jiný port nepředstavuje jinou identitu. Použijte desktopovou aplikaci Termix nebo toto webové uživatelské rozhraní obsluhujte z jiného názvu hostitele.",
"retry": "Zkusit znovu",
"reload": "Znovu načíst",
"copyUrl": "Kopírovat URL",
"urlCopied": "URL zkopírována",
"copyFailed": "Kopírování adresy URL se nezdařilo",
"openExternally": "Otevřít externě",
"isolationUnavailable": "Tento prohlížeč nedokáže izolovat webové koncové body od vaší relace Termix. Použijte prohlížeč s podporou iframe bez přihlašovacích údajů.",
"isolationNotice": "Vložené stránky používají izolovanou relaci. Pro stránky vyžadující úložiště nebo vyskakovací přihlášení použijte v desktopové aplikaci izolované okno.",
"openIsolatedWindow": "Otevřené izolované okno"
},
"hosts": {
"tabWebUi": "Webové uživatelské rozhraní",
"webUiEndpointLabel": "Koncový bod {{number}}",
"webUiIntegration": "Webové uživatelské rozhraní",
"webUiEndpointsSection": "Koncové body",
"webUiNoEndpoints": "Zatím žádné webové koncové body. Vytvořte si je pomocí možnosti Přidat koncový bod.",
"webUiEndpointsDisabled": "Zapněte webové uživatelské rozhraní výše a přidejte koncové body.",
"webUiRequirementsText": "Prohlížeč otevírá přímý koncový bod, takže jeho port musí být odtud dosažitelný. Termix místo toho otevírá SSH tunel přes SSH připojení tohoto hostitele, takže port musí být dostupný pouze z hostitele.",
"enableWebUi": "Povolit webové koncové body",
"enableWebUiDesc": "Otevřete webová rozhraní tohoto hostitele z postranního panelu.",
"webUiAddEndpoint": "Přidat koncový bod",
"webUiAddress": "Adresa",
"webUiOpening": "Otevírací",
"webUiBindHostDesc": "Kam se backend váže s forwardem. Pro 127.0.0.1, ke které má přístup pouze počítač se systémem Termix, nechte prázdné.",
"webUiBindHostExposed": "Tato adresa je dosažitelná i mimo počítač Termix, takže kdokoli, kdo má přístup k portu, získá toto webové rozhraní bez nutnosti přihlášení.",
"webUiLocalPort": "Místní port",
"webUiLocalPortAuto": "Automatický",
"webUiLocalPortDesc": "Na kterém portu naslouchá forwarding. Ponechte prázdné, pokud chcete, aby si systém vybral, nebo nastavte port, pokud musí být port znám předem, například v případě kontejneru, který jej publikuje.",
"webUiNewEndpointLabel": "Nový koncový bod",
"webUiLabel": "Označení",
"webUiScheme": "Systém",
"webUiPort": "Port",
"webUiPath": "Cesta",
"webUiAccess": "Přístup",
"webUiAccessDirect": "Řídit",
"webUiAccessTunnel": "SSH tunel",
"webUiAccessTunnelUnavailable": "SSH tunel vyžaduje povolené SSH na tomto hostiteli a původ připojení na tomto zařízení.",
"webUiRender": "Otevřít v",
"webUiRenderExternal": "Izolované okno plochy",
"webUiRenderEmbedded": "Záložka Termix",
"webUiRenderEmbeddedDesc": "Vkládání je nejlepším řešením: některá webová rozhraní se odmítají načíst v rámci a přihlášení, které se spoléhá na soubory cookie stejného webu, se může zacyklit. Externí prohlížeč funguje vždy.",
"webUiIgnoreCert": "Povolit neplatný certifikát",
"webUiIgnoreCertDesc": "Pouze přímé koncové body. Zpětná smyčka je již vyňata, takže to nemá žádný vliv na koncové body tunelu.",
"webUiRemoveEndpoint": "Odebrat koncový bod",
"webUiOpenFailed": "Nepodařilo se otevřít webový koncový bod",
"bindHost": "Vázat hostitele"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Ingen vært valgt",
"notFound": "Dette slutpunkt findes ikke længere.",
"tunnelUnreachableFromBrowser": "Dette endpoint tunnelerer til 127.0.0.1 på den maskine, der kører Termix, som din browser ikke kan nå. Indstil endpointens Bind Host til en adresse, som maskinen svarer på, f.eks. 0.0.0.0, og giv den en fast lokal port.",
"tunnelSharesSessionCookie": "Åbning af denne tunnel ville sende din Termix-session til den tunnelerede tjeneste, fordi din browser ville nå den på det samme værtsnavn, som Termix serveres fra. Cookies ignorerer porten, så de to kan ikke skelnes fra hinanden. Brug Termix-desktopappen, eller åbn Termix på localhost.",
"directSharesSessionCookie": "Åbning af dette slutpunkt ville sende din Termix-session til det, fordi din browser når det på det samme sted, som Termix serveres fra. Cookies ignorerer porten, så en anden port er ikke en anden identitet. Brug Termix-desktopappen, eller server denne webgrænseflade fra et andet værtsnavn.",
"retry": "Prøv igen",
"reload": "Genindlæs",
"copyUrl": "Kopiér URL",
"urlCopied": "URL kopieret",
"copyFailed": "Kunne ikke kopiere URL'en",
"openExternally": "Åbn eksternt",
"isolationUnavailable": "Denne browser kan ikke isolere web-slutpunkter fra din Termix-session. Brug en browser med understøttelse af iframes uden legitimationsoplysninger.",
"isolationNotice": "Integrerede sider bruger en isoleret session. For sider, der kræver lagerplads eller pop op-login, skal du bruge et isoleret vindue i desktop-appen.",
"openIsolatedWindow": "Åbn isoleret vindue"
},
"hosts": {
"tabWebUi": "Webgrænseflade",
"webUiEndpointLabel": "Endepunkt {{number}}",
"webUiIntegration": "Webgrænseflade",
"webUiEndpointsSection": "Endepunkter",
"webUiNoEndpoints": "Ingen web-slutpunkter endnu. Brug Tilføj slutpunkt til at oprette et.",
"webUiEndpointsDisabled": "Slå webgrænsefladen til ovenfor for at tilføje slutpunkter.",
"webUiRequirementsText": "Et direkte slutpunkt åbnes af din browser, så dens port skal kunne nås herfra. En SSH-tunnel åbnes i stedet af Termix over denne værts SSH-forbindelse, så porten skal kun kunne nås fra værten.",
"enableWebUi": "Aktivér webslutpunkter",
"enableWebUiDesc": "Åbn denne værts webgrænseflader fra sidebjælken.",
"webUiAddEndpoint": "Tilføj slutpunkt",
"webUiAddress": "Adresse",
"webUiOpening": "Åbning",
"webUiBindHostDesc": "Hvor backend binder forward. Lad feltet 127.0.0.1 stå tomt, som kun den maskine, der kører Termix, kan nå.",
"webUiBindHostExposed": "Denne adresse kan nås ud over Termix-maskinen, så alle, der kan nå porten, får denne webgrænseflade uden login.",
"webUiLocalPort": "Lokal havn",
"webUiLocalPortAuto": "Automatisk",
"webUiLocalPortDesc": "Hvilken port videresendelsen lytter på. Lad feltet stå tomt for at lade systemet vælge, eller angiv en port, når porten skal være kendt på forhånd, f.eks. en container, der udgiver den.",
"webUiNewEndpointLabel": "Nyt slutpunkt",
"webUiLabel": "Mærke",
"webUiScheme": "Ordning",
"webUiPort": "Havn",
"webUiPath": "Sti",
"webUiAccess": "Adgang",
"webUiAccessDirect": "Direkte",
"webUiAccessTunnel": "SSH-tunnel",
"webUiAccessTunnelUnavailable": "En SSH-tunnel kræver, at SSH er aktiveret på denne vært, og at denne enhed har en forbindelsesoprindelse.",
"webUiRender": "Åbn om",
"webUiRenderExternal": "Isoleret skrivebordsvindue",
"webUiRenderEmbedded": "Termix-fanen",
"webUiRenderEmbeddedDesc": "Indlejring er den bedste løsning: nogle webgrænseflader nægter at indlæse i en frame, og et login, der er afhængig af cookies fra samme websted, kan gå i løkke. Den eksterne browser fungerer altid.",
"webUiIgnoreCert": "Tillad ugyldigt certifikat",
"webUiIgnoreCertDesc": "Kun direkte slutpunkter. Loopback er allerede undtaget, så dette har ingen effekt på tunnelslutpunkter.",
"webUiRemoveEndpoint": "Fjern slutpunkt",
"webUiOpenFailed": "Kunne ikke åbne web-slutpunktet",
"bindHost": "Bind vært"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Kein Host ausgewählt",
"notFound": "Dieser Endpunkt existiert nicht mehr.",
"tunnelUnreachableFromBrowser": "Dieser Endpunkt leitet eine Verbindung zu 127.0.0.1 auf dem Rechner weiter, auf dem Termix ausgeführt wird. Ihr Browser kann diese Adresse nicht erreichen. Legen Sie für den Endpunkt als Bind-Host eine Adresse fest, unter der dieser Rechner antwortet, z. B. 0.0.0.0, und weisen Sie ihm einen festen lokalen Port zu.",
"tunnelSharesSessionCookie": "Durch das Öffnen dieses Tunnels wird Ihre Termix-Sitzung an den getunnelten Dienst weitergeleitet, da Ihr Browser diesen unter demselben Hostnamen erreicht, unter dem Termix bereitgestellt wird. Cookies ignorieren den Port, daher können die beiden nicht unterschieden werden. Verwenden Sie die Termix-Desktop-App oder öffnen Sie Termix auf localhost.",
"directSharesSessionCookie": "Wenn Sie diesen Endpunkt öffnen, wird Ihre Termix-Sitzung dorthin gesendet, da Ihr Browser ihn über dieselbe Website erreicht, von der auch Termix bereitgestellt wird. Cookies ignorieren den Port, daher bedeutet ein anderer Port keine andere Identität. Verwenden Sie die Termix-Desktop-App oder stellen Sie diese Web-Oberfläche über einen anderen Hostnamen bereit.",
"retry": "Erneut versuchen",
"reload": "Neu laden",
"copyUrl": "URL kopieren",
"urlCopied": "URL kopiert",
"copyFailed": "URL konnte nicht kopiert werden",
"openExternally": "Extern öffnen",
"isolationUnavailable": "Dieser Browser kann Web-Endpunkte nicht von Ihrer Termix-Sitzung isolieren. Verwenden Sie einen Browser, der iFrames ohne Anmeldeinformationen unterstützt.",
"isolationNotice": "Eingebettete Seiten verwenden eine separate Sitzung. Für Seiten, die Speicherplatz benötigen oder eine Popup-Anmeldung erfordern, verwenden Sie ein separates Fenster in der Desktop-App.",
"openIsolatedWindow": "Isoliertes Fenster öffnen"
},
"hosts": {
"tabWebUi": "Web-Benutzeroberfläche",
"webUiEndpointLabel": "Endpunkt {{number}}",
"webUiIntegration": "Web-Benutzeroberfläche",
"webUiEndpointsSection": "Endpunkte",
"webUiNoEndpoints": "Noch keine Web-Endpunkte vorhanden. Verwenden Sie „Endpunkt hinzufügen“, um einen zu erstellen.",
"webUiEndpointsDisabled": "Aktivieren Sie die Web-Benutzeroberfläche oben, um Endpunkte hinzuzufügen.",
"webUiRequirementsText": "Ihr Browser öffnet einen direkten Endpunkt, daher muss dessen Port von hier aus erreichbar sein. Termix öffnet stattdessen einen SSH-Tunnel über die SSH-Verbindung dieses Hosts, sodass der Port nur vom Host aus erreichbar sein muss.",
"enableWebUi": "Web-Endpunkte aktivieren",
"enableWebUiDesc": "Öffnen Sie die Web-Oberflächen dieses Hosts über die Seitenleiste.",
"webUiAddEndpoint": "Endpunkt hinzufügen",
"webUiAddress": "Adresse",
"webUiOpening": "Öffnung",
"webUiBindHostDesc": "Hier bindet das Backend das Forwarding. Lassen Sie das Feld für 127.0.0.1 leer, da diese Adresse nur von dem Rechner aus erreichbar ist, auf dem Termix läuft.",
"webUiBindHostExposed": "Diese Adresse ist auch außerhalb des Termix-Rechners erreichbar, sodass jeder, der den Port erreichen kann, diese Web-Oberfläche ohne Anmeldung aufrufen kann.",
"webUiLocalPort": "Lokaler Port",
"webUiLocalPortAuto": "Automatisch",
"webUiLocalPortDesc": "Auf welchem Port der Forwarder lauscht. Lassen Sie das Feld leer, damit das System einen Port auswählt, oder geben Sie einen Port an, wenn dieser im Voraus bekannt sein muss, z. B. von einem Container, der ihn veröffentlicht.",
"webUiNewEndpointLabel": "Neuer Endpunkt",
"webUiLabel": "Label",
"webUiScheme": "Schema",
"webUiPort": "Port",
"webUiPath": "Pfad",
"webUiAccess": "Zugang",
"webUiAccessDirect": "Direkt",
"webUiAccessTunnel": "SSH-Tunnel",
"webUiAccessTunnelUnavailable": "Für einen SSH-Tunnel muss SSH auf diesem Host aktiviert sein und dieses Gerät als Verbindungsursprung verwendet werden.",
"webUiRender": "Öffnen in",
"webUiRenderExternal": "Isoliertes Desktop-Fenster",
"webUiRenderEmbedded": "Termix-Registerkarte",
"webUiRenderEmbeddedDesc": "Die Einbettung funktioniert nach bestem Bemühen: Manche Web-Oberflächen lassen sich nicht in einem Frame laden, und ein Login, der auf Same-Site-Cookies basiert, kann in einer Endlosschleife hängen bleiben. Der externe Browser funktioniert immer.",
"webUiIgnoreCert": "Ungültiges Zertifikat zulassen",
"webUiIgnoreCertDesc": "Nur direkte Endpunkte. Loopback ist bereits ausgenommen, daher hat dies keine Auswirkungen auf Tunnelendpunkte.",
"webUiRemoveEndpoint": "Endpunkt entfernen",
"webUiOpenFailed": "Der Web-Endpunkt konnte nicht geöffnet werden.",
"bindHost": "Bind-Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"notFound": "Αυτό το τελικό σημείο δεν υπάρχει πλέον.",
"tunnelUnreachableFromBrowser": "Αυτό το τελικό σημείο συνδέεται με την διεύθυνση 127.0.0.1 στον υπολογιστή που εκτελεί το Termix, στην οποία δεν μπορεί να φτάσει το πρόγραμμα περιήγησής σας. Ορίστε τον Bind Host του τελικού σημείου σε μια διεύθυνση στην οποία απαντά ο υπολογιστής, όπως 0.0.0.0, και δώστε του μια σταθερή τοπική θύρα.",
"tunnelSharesSessionCookie": "Το άνοιγμα αυτής της σήραγγας θα στείλει την συνεδρία σας στο Termix στην υπηρεσία που βρίσκεται σε σήραγγα, επειδή το πρόγραμμα περιήγησής σας θα φτάσει σε αυτήν με το ίδιο όνομα κεντρικού υπολογιστή από το οποίο εξυπηρετείται το Termix. Τα cookies αγνοούν τη θύρα, επομένως δεν είναι δυνατή η διάκριση μεταξύ των δύο. Χρησιμοποιήστε την εφαρμογή Termix για υπολογιστές ή ανοίξτε το Termix στο localhost.",
"directSharesSessionCookie": "Το άνοιγμα αυτού του τελικού σημείου θα στείλει την περίοδο λειτουργίας του Termix σε αυτό, επειδή το πρόγραμμα περιήγησής σας φτάνει σε αυτό από τον ίδιο ιστότοπο από τον οποίο παρέχεται το Termix. Τα cookies αγνοούν τη θύρα, επομένως μια διαφορετική θύρα δεν αποτελεί διαφορετική ταυτότητα. Χρησιμοποιήστε την εφαρμογή Termix για υπολογιστές ή εκτελέστε αυτό το περιβάλλον χρήστη ιστού από διαφορετικό όνομα κεντρικού υπολογιστή.",
"retry": "Δοκιμάζω πάλι",
"reload": "Γεμίζω πάλι",
"copyUrl": "Αντιγραφή URL",
"urlCopied": "Η διεύθυνση URL αντιγράφηκε",
"copyFailed": "Η αντιγραφή της διεύθυνσης URL απέτυχε",
"openExternally": "Άνοιγμα εξωτερικά",
"isolationUnavailable": "Αυτό το πρόγραμμα περιήγησης δεν μπορεί να απομονώσει τα τελικά σημεία ιστού από την περίοδο λειτουργίας Termix. Χρησιμοποιήστε ένα πρόγραμμα περιήγησης με υποστήριξη iframe χωρίς διαπιστευτήρια.",
"isolationNotice": "Οι ενσωματωμένες σελίδες χρησιμοποιούν μια απομονωμένη περίοδο σύνδεσης. Για σελίδες που απαιτούν χώρο αποθήκευσης ή σύνδεση σε αναδυόμενο παράθυρο, χρησιμοποιήστε ένα απομονωμένο παράθυρο στην εφαρμογή για υπολογιστές.",
"openIsolatedWindow": "Άνοιγμα απομονωμένου παραθύρου"
},
"hosts": {
"tabWebUi": "Διεπαφή χρήστη ιστού",
"webUiEndpointLabel": "Τελικό σημείο {{number}}",
"webUiIntegration": "Διεπαφή χρήστη ιστού",
"webUiEndpointsSection": "Τελικά σημεία",
"webUiNoEndpoints": "Δεν υπάρχουν ακόμη τελικά σημεία ιστού. Χρησιμοποιήστε την επιλογή Προσθήκη τελικού σημείου για να δημιουργήσετε ένα.",
"webUiEndpointsDisabled": "Ενεργοποιήστε το περιβάλλον χρήστη ιστού παραπάνω για να προσθέσετε τελικά σημεία.",
"webUiRequirementsText": "Ένα άμεσο τελικό σημείο ανοίγει από το πρόγραμμα περιήγησής σας, επομένως η θύρα του πρέπει να είναι προσβάσιμη από εδώ. Μια σήραγγα SSH ανοίγει από το Termix μέσω της σύνδεσης SSH αυτού του κεντρικού υπολογιστή, επομένως η θύρα πρέπει να είναι προσβάσιμη μόνο από τον κεντρικό υπολογιστή.",
"enableWebUi": "Ενεργοποίηση τελικών σημείων ιστού",
"enableWebUiDesc": "Ανοίξτε τις διεπαφές ιστού αυτού του κεντρικού υπολογιστή από την πλαϊνή γραμμή.",
"webUiAddEndpoint": "Προσθήκη τελικού σημείου",
"webUiAddress": "Διεύθυνση",
"webUiOpening": "Ανοιγμα",
"webUiBindHostDesc": "Όπου το backend συνδέει το forward. Αφήστε κενό για το 127.0.0.1, το οποίο μπορεί να φτάσει μόνο το μηχάνημα που εκτελεί το Termix.",
"webUiBindHostExposed": "Αυτή η διεύθυνση είναι προσβάσιμη πέρα από το μηχάνημα Termix, επομένως όποιος μπορεί να φτάσει στη θύρα λαμβάνει αυτό το περιβάλλον χρήστη ιστού χωρίς σύνδεση.",
"webUiLocalPort": "Τοπικό Λιμάνι",
"webUiLocalPortAuto": "Αυτόματο",
"webUiLocalPortDesc": "Σε ποια θύρα ακούει η προώθηση. Αφήστε το κενό για να επιλέξει το σύστημα ή ορίστε μία όταν η θύρα πρέπει να είναι γνωστή εκ των προτέρων, όπως ένα κοντέινερ που τη δημοσιεύει.",
"webUiNewEndpointLabel": "Νέο τελικό σημείο",
"webUiLabel": "Επιγραφή",
"webUiScheme": "Σχέδιο",
"webUiPort": "Λιμάνι",
"webUiPath": "Μονοπάτι",
"webUiAccess": "Πρόσβαση",
"webUiAccessDirect": "Απευθείας",
"webUiAccessTunnel": "Σήραγγα SSH",
"webUiAccessTunnelUnavailable": "Μια σήραγγα SSH απαιτεί ενεργοποιημένο SSH σε αυτόν τον κεντρικό υπολογιστή και μια προέλευση σύνδεσης αυτής της συσκευής.",
"webUiRender": "Άνοιγμα σε",
"webUiRenderExternal": "Απομονωμένο παράθυρο επιφάνειας εργασίας",
"webUiRenderEmbedded": "Καρτέλα Termix",
"webUiRenderEmbeddedDesc": "Η ενσωμάτωση είναι η καλύτερη δυνατή προσπάθεια: ορισμένες διεπαφές ιστού αρνούνται να φορτώσουν σε ένα πλαίσιο και μια σύνδεση που βασίζεται σε cookies του ίδιου ιστότοπου ενδέχεται να επαναλαμβάνεται. Το εξωτερικό πρόγραμμα περιήγησης λειτουργεί πάντα.",
"webUiIgnoreCert": "Να επιτρέπεται το μη έγκυρο πιστοποιητικό",
"webUiIgnoreCertDesc": "Μόνο άμεσα τελικά σημεία. Η επαναφορά μέσω βρόχου εξαιρείται ήδη, επομένως αυτό δεν έχει καμία επίδραση στα τελικά σημεία σήραγγας.",
"webUiRemoveEndpoint": "Αφαίρεση τελικού σημείου",
"webUiOpenFailed": "Αποτυχία ανοίγματος του τελικού σημείου ιστού",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Ningún host seleccionado",
"notFound": "Este punto final ya no existe.",
"tunnelUnreachableFromBrowser": "Este punto final se conecta mediante túnel a la dirección IP 127.0.0.1 de la máquina que ejecuta Termix, a la que su navegador no puede acceder. Configure el host de enlace del punto final con una dirección a la que la máquina responda, como 0.0.0.0, y asígnele un puerto local fijo.",
"tunnelSharesSessionCookie": "Al abrir este túnel, tu sesión de Termix se conectará al servicio tunelizado, ya que tu navegador accederá a él a través del mismo nombre de host desde el que se sirve Termix. Las cookies no tienen en cuenta el puerto, por lo que no se puede distinguir entre ambos. Usa la aplicación de escritorio de Termix o abre Termix en localhost.",
"directSharesSessionCookie": "Al abrir este punto final, tu sesión de Termix se redirigirá a él, ya que tu navegador lo alcanza desde el mismo sitio donde se sirve Termix. Las cookies ignoran el puerto, por lo que un puerto diferente no implica una identidad diferente. Usa la aplicación de escritorio de Termix o accede a esta interfaz web desde un nombre de host diferente.",
"retry": "Reintentar",
"reload": "Recargar",
"copyUrl": "Copiar URL",
"urlCopied": "URL copiada",
"copyFailed": "Error al copiar la URL",
"openExternally": "Abrir externamente",
"isolationUnavailable": "Este navegador no puede aislar los puntos finales web de su sesión de Termix. Utilice un navegador compatible con iframes sin credenciales.",
"isolationNotice": "Las páginas integradas utilizan una sesión aislada. Para las páginas que requieren almacenamiento o inicio de sesión emergente, utilice una ventana aislada en la aplicación de escritorio.",
"openIsolatedWindow": "Abrir ventana aislada"
},
"hosts": {
"tabWebUi": "Interfaz de usuario web",
"webUiEndpointLabel": "Punto final {{number}}",
"webUiIntegration": "Interfaz de usuario web",
"webUiEndpointsSection": "Puntos finales",
"webUiNoEndpoints": "Aún no hay puntos de acceso web. Utilice la opción \"Agregar punto de acceso\" para crear uno.",
"webUiEndpointsDisabled": "Active la interfaz web que aparece arriba para añadir puntos finales.",
"webUiRequirementsText": "Tu navegador abre un punto final directo, por lo que su puerto debe ser accesible desde aquí. En cambio, Termix abre un túnel SSH a través de la conexión SSH de este host, por lo que el puerto solo tiene que ser accesible desde el host.",
"enableWebUi": "Habilitar puntos finales web",
"enableWebUiDesc": "Abra las interfaces web de este host desde la barra lateral.",
"webUiAddEndpoint": "Agregar punto final",
"webUiAddress": "Dirección",
"webUiOpening": "Apertura",
"webUiBindHostDesc": "Donde el backend enlaza el forward. Deje en blanco para 127.0.0.1, al que solo puede acceder la máquina que ejecuta Termix.",
"webUiBindHostExposed": "Esta dirección es accesible desde fuera de la máquina Termix, por lo que cualquiera que pueda acceder al puerto obtiene esta interfaz web sin necesidad de iniciar sesión.",
"webUiLocalPort": "Puerto local",
"webUiLocalPortAuto": "Automático",
"webUiLocalPortDesc": "En qué puerto escucha el reenvío. Déjelo en blanco para que el sistema lo seleccione automáticamente, o establezca uno cuando sea necesario conocer el puerto de antemano, como en el caso de un contenedor que lo publica.",
"webUiNewEndpointLabel": "Nuevo punto final",
"webUiLabel": "Etiqueta",
"webUiScheme": "Esquema",
"webUiPort": "Puerto",
"webUiPath": "Ruta",
"webUiAccess": "Acceso",
"webUiAccessDirect": "Directa",
"webUiAccessTunnel": "Túnel SSH",
"webUiAccessTunnelUnavailable": "Para que exista un túnel SSH, es necesario que SSH esté habilitado en este host y que el origen de la conexión sea este dispositivo.",
"webUiRender": "Abrir en",
"webUiRenderExternal": "Ventana de escritorio aislada",
"webUiRenderEmbedded": "pestaña Termix",
"webUiRenderEmbeddedDesc": "La incrustación es un intento desesperado: algunas interfaces web se niegan a cargarse dentro de un marco, y un inicio de sesión que dependa de cookies del mismo sitio puede entrar en un bucle. El navegador externo siempre funciona.",
"webUiIgnoreCert": "Permitir certificado no válido",
"webUiIgnoreCertDesc": "Solo se aplican puntos finales directos. Loopback ya está exento, por lo que esto no afecta a los puntos finales de túnel.",
"webUiRemoveEndpoint": "Eliminar punto final",
"webUiOpenFailed": "No se pudo abrir el punto final web.",
"bindHost": "Host de enlace"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Ei valittua isäntää",
"notFound": "Tätä päätepistettä ei ole enää olemassa.",
"tunnelUnreachableFromBrowser": "Tämä päätepiste muodostaa tunnelin Termixiä käyttävän koneen osoitteeseen 127.0.0.1, johon selaimesi ei pääse. Aseta päätepisteen Bind Host -arvoksi osoitteen, jolla kone vastaa, kuten 0.0.0.0, ja anna sille kiinteä paikallinen portti.",
"tunnelSharesSessionCookie": "Tämän tunnelin avaaminen lähettäisi Termix-istuntosi tunneloituun palveluun, koska selaimesi päätyisi siihen samalla isäntänimellä, josta Termixiä palvellaan. Evästeet jättävät portin huomiotta, joten niitä ei voida erottaa toisistaan. Käytä Termix-työpöytäsovellusta tai avaa Termix paikallisessa koneessa.",
"directSharesSessionCookie": "Tämän päätepisteen avaaminen lähettäisi Termix-istuntosi siihen, koska selaimesi käyttää sitä samalla sivustolla, josta Termixiä käytetään. Evästeet eivät huomioi porttia, joten eri portti ei ole eri identiteetti. Käytä Termix-työpöytäsovellusta tai käytä tätä verkkokäyttöliittymää eri isäntänimellä.",
"retry": "Yritä uudelleen",
"reload": "Lataa uudelleen",
"copyUrl": "Kopioi URL-osoite",
"urlCopied": "URL-osoite kopioitu",
"copyFailed": "URL-osoitteen kopiointi epäonnistui",
"openExternally": "Avaa ulkopuolelta",
"isolationUnavailable": "Tämä selain ei voi eristää verkkopäätepisteitä Termix-istunnostasi. Käytä selainta, joka tukee tunnistetiedotonta iframe-kehystä.",
"isolationNotice": "Upotetut sivut käyttävät eristettyä istuntoa. Sivuille, jotka vaativat tallennustilaa tai ponnahdusikkunakirjautumista, käytä eristettyä ikkunaa työpöytäsovelluksessa.",
"openIsolatedWindow": "Avaa eristetty ikkuna"
},
"hosts": {
"tabWebUi": "Verkkokäyttöliittymä",
"webUiEndpointLabel": "Päätepiste {{number}}",
"webUiIntegration": "Verkkokäyttöliittymä",
"webUiEndpointsSection": "Päätepisteet",
"webUiNoEndpoints": "Ei vielä verkkopäätepisteitä. Luo sellainen Lisää päätepiste -toiminnolla.",
"webUiEndpointsDisabled": "Ota yllä oleva verkkokäyttöliittymä käyttöön lisätäksesi päätepisteitä.",
"webUiRequirementsText": "Selaimesi avaa suoran päätepisteen, joten sen portin on oltava tavoitettavissa täältä. Termix avaa SSH-tunnelin tämän isännän SSH-yhteyden kautta, joten portin on oltava tavoitettavissa vain isännästä.",
"enableWebUi": "Ota käyttöön verkkopäätepisteet",
"enableWebUiDesc": "Avaa tämän isännän web-käyttöliittymät sivupalkista.",
"webUiAddEndpoint": "Lisää päätepiste",
"webUiAddress": "Osoite",
"webUiOpening": "Avaaminen",
"webUiBindHostDesc": "Mihin taustajärjestelmä sitoo eteenpäin suuntautuvan järjestelmän. Jätä tyhjäksi 127.0.0.1, johon vain Termixiä suorittava kone pääsee käsiksi.",
"webUiBindHostExposed": "Tämä osoite on saavutettavissa Termix-koneen ulkopuolelta, joten kuka tahansa, joka pääsee portin kautta, saa tämän web-käyttöliittymän ilman kirjautumista.",
"webUiLocalPort": "Paikallinen satama",
"webUiLocalPortAuto": "Automaattinen",
"webUiLocalPortDesc": "Mitä porttia edelleenlähetys kuuntelee. Jätä tyhjäksi, jos haluat järjestelmän valitsevan portin, tai aseta portti etukäteen, kuten esimerkiksi säilö, joka julkaisee sen.",
"webUiNewEndpointLabel": "Uusi päätepiste",
"webUiLabel": "Tarra",
"webUiScheme": "Järjestelmä",
"webUiPort": "Portti",
"webUiPath": "Polku",
"webUiAccess": "Pääsy",
"webUiAccessDirect": "Suoraan",
"webUiAccessTunnel": "SSH-tunneli",
"webUiAccessTunnelUnavailable": "SSH-tunneli vaatii SSH:n käyttöönoton tällä isännöintikoneella ja yhteyden alkuperän tällä laitteella.",
"webUiRender": "Avaa",
"webUiRenderExternal": "Eristetty työpöytäikkuna",
"webUiRenderEmbedded": "Termix-välilehti",
"webUiRenderEmbeddedDesc": "Upottaminen on parasta: jotkin verkkoliittymät kieltäytyvät latautumasta kehykseen, ja saman sivuston evästeisiin perustuva kirjautuminen voi tapahtua silmukassa. Ulkoinen selain toimii aina.",
"webUiIgnoreCert": "Salli virheellinen varmenne",
"webUiIgnoreCertDesc": "Vain suorat päätepisteet. Loopback on jo vapautettu, joten tällä ei ole vaikutusta tunnelin päätepisteisiin.",
"webUiRemoveEndpoint": "Poista päätepiste",
"webUiOpenFailed": "Verkkopäätepisteen avaaminen epäonnistui",
"bindHost": "Sido isäntä"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Aucun hôte sélectionné",
"notFound": "Ce point de terminaison n'existe plus.",
"tunnelUnreachableFromBrowser": "Ce point de terminaison établit un tunnel vers l'adresse 127.0.0.1 sur la machine exécutant Termix, adresse inaccessible à votre navigateur. Configurez l'hôte de liaison du point de terminaison sur une adresse à laquelle la machine répond, par exemple 0.0.0.0, et attribuez-lui un port local fixe.",
"tunnelSharesSessionCookie": "L'ouverture de ce tunnel redirigerait votre session Termix vers le service tunnelisé, car votre navigateur y accéderait via le même nom d'hôte que celui utilisé par Termix. Les cookies ignorent le port, il est donc impossible de les distinguer. Utilisez l'application de bureau Termix ou ouvrez Termix en local.",
"directSharesSessionCookie": "Ouvrir ce point de terminaison redirigerait votre session Termix vers celui-ci, car votre navigateur y accède depuis le même site que celui où Termix est hébergé. Les cookies ignorent le port ; un port différent ne constitue donc pas une identité différente. Utilisez l’application de bureau Termix ou hébergez cette interface web depuis un autre nom d’hôte.",
"retry": "Réessayer",
"reload": "Recharger",
"copyUrl": "Copier l'URL",
"urlCopied": "URL copiée",
"copyFailed": "Impossible de copier l'URL",
"openExternally": "Ouvrir avec l’éditeur externe",
"isolationUnavailable": "Ce navigateur ne permet pas d'isoler les points de terminaison Web de votre session Termix. Veuillez utiliser un navigateur compatible avec les iframes sans authentification.",
"isolationNotice": "Les pages intégrées utilisent une session isolée. Pour les pages nécessitant un stockage ou une connexion via une fenêtre contextuelle, utilisez une fenêtre isolée dans l'application de bureau.",
"openIsolatedWindow": "Ouvrir une fenêtre isolée"
},
"hosts": {
"tabWebUi": "Interface utilisateur Web",
"webUiEndpointLabel": "Point de terminaison {{number}}",
"webUiIntegration": "Interface utilisateur Web",
"webUiEndpointsSection": "Points d'extrémité",
"webUiNoEndpoints": "Aucun point de terminaison web pour le moment. Utilisez « Ajouter un point de terminaison » pour en créer un.",
"webUiEndpointsDisabled": "Activez l'interface Web ci-dessus pour ajouter des points de terminaison.",
"webUiRequirementsText": "Votre navigateur ouvre un point de terminaison direct ; son port doit donc être accessible depuis ici. Termix, quant à lui, ouvre un tunnel SSH via la connexion SSH de cet hôte ; le port doit donc seulement être accessible depuis l'hôte.",
"enableWebUi": "Activer les points de terminaison Web",
"enableWebUiDesc": "Ouvrez les interfaces web de cet hébergeur depuis la barre latérale.",
"webUiAddEndpoint": "Ajouter un point de terminaison",
"webUiAddress": "Adresse",
"webUiOpening": "Ouverture",
"webUiBindHostDesc": "Où le serveur d'arrière-plan lie le serveur de transfert. Laissez vide pour 127.0.0.1, adresse accessible uniquement par la machine exécutant Termix.",
"webUiBindHostExposed": "Cette adresse est accessible au-delà de la machine Termix, donc toute personne pouvant accéder au port obtient cette interface web sans avoir à se connecter.",
"webUiLocalPort": "Port local",
"webUiLocalPortAuto": "Automatique",
"webUiLocalPortDesc": "Port d'écoute du serveur de redirection. Laissez ce champ vide pour laisser le système choisir le port, ou spécifiez-en un si celui-ci doit être connu à l'avance, par exemple pour un conteneur qui le publie.",
"webUiNewEndpointLabel": "Nouveau point de terminaison",
"webUiLabel": "Étiquette",
"webUiScheme": "Schème",
"webUiPort": "Port",
"webUiPath": "Chemin",
"webUiAccess": "Accéder",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "Tunnel SSH",
"webUiAccessTunnelUnavailable": "Un tunnel SSH nécessite que SSH soit activé sur cet hôte et que la connexion provienne de cet appareil.",
"webUiRender": "Ouvrir dans",
"webUiRenderExternal": "Fenêtre de bureau isolée",
"webUiRenderEmbedded": "Onglet Termix",
"webUiRenderEmbeddedDesc": "L'intégration est possible dans les meilleures conditions : certaines interfaces web refusent de se charger dans un cadre, et une connexion reposant sur les cookies du même site peut générer une boucle. Le navigateur externe fonctionne toujours.",
"webUiIgnoreCert": "Autoriser les certificats invalides",
"webUiIgnoreCertDesc": "Seuls les points de terminaison directs sont concernés. Les points de terminaison de bouclage sont déjà exemptés ; cette mesure n’a donc aucun impact sur les points de terminaison de tunnel.",
"webUiRemoveEndpoint": "Supprimer le point de terminaison",
"webUiOpenFailed": "Impossible d'ouvrir le point de terminaison Web",
"bindHost": "Hôte de liaison"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "לא נבחר מארח",
"notFound": "נקודת קצה זו אינה קיימת עוד.",
"tunnelUnreachableFromBrowser": "נקודת קצה זו עוברת ל-127.0.0.1 במחשב בו פועל Termix, שאליה הדפדפן שלך אינו יכול להגיע. הגדר את Bind Host של נקודת הקצה לכתובת שעליה המכונה עונה, כגון 0.0.0.0, ותן לה יציאה מקומית קבועה.",
"tunnelSharesSessionCookie": "פתיחת מנהרה זו תשלח את סשן Termix שלך לשירות המנהר, מכיוון שהדפדפן שלך יגיע אליו באותו שם מארח שממנו Termix מוגש. קובצי Cookie מתעלמים מהפורט, כך שלא ניתן להבחין ביניהם. השתמש באפליקציית שולחן העבודה של Termix, או פתח את Termix ב-localhost.",
"directSharesSessionCookie": "פתיחת נקודת קצה זו תשלח אליה את סשן Termix שלך, מכיוון שהדפדפן שלך מגיע אליה באותו אתר שממנו Termix מוגש. קובצי Cookie מתעלמים מהפורט, כך שפורט שונה אינו זהות שונה. השתמש באפליקציית שולחן העבודה של Termix, או שרת ממשק משתמש אינטרנט זה משם מארח אחר.",
"retry": "נסה שוב",
"reload": "לִטעוֹן מִחָדָשׁ",
"copyUrl": "העתקת כתובת URL",
"urlCopied": "כתובת האתר הועתקה",
"copyFailed": "נכשלה העתקת כתובת ה-URL",
"openExternally": "פתוח חיצונית",
"isolationUnavailable": "דפדפן זה אינו יכול לבודד נקודות קצה של אינטרנט מהפעלת Termix שלך. השתמש בדפדפן עם תמיכה ב-iframe ללא אישורים.",
"isolationNotice": "דפים מוטמעים משתמשים בהפעלה מבודדת. עבור דפים הדורשים אחסון או כניסה קופצת, השתמשו בחלון מבודד באפליקציית שולחן העבודה.",
"openIsolatedWindow": "פתח חלון מבודד"
},
"hosts": {
"tabWebUi": "ממשק משתמש אינטרנטי",
"webUiEndpointLabel": "נקודת קצה {{number}}",
"webUiIntegration": "ממשק משתמש אינטרנטי",
"webUiEndpointsSection": "נקודות קצה",
"webUiNoEndpoints": "אין עדיין נקודות קצה באינטרנט. השתמש באפשרות \"הוסף נקודת קצה\" כדי ליצור אחת.",
"webUiEndpointsDisabled": "הפעל את ממשק המשתמש של האינטרנט למעלה כדי להוסיף נקודות קצה.",
"webUiRequirementsText": "נקודת קצה ישירה נפתחת על ידי הדפדפן שלך, כך שהפורט שלה חייב להיות נגיש מכאן. מנהרת SSH נפתחת על ידי Termix דרך חיבור ה-SSH של מחשב מארח זה, כך שהפורט צריך להיות נגיש רק מהמארח.",
"enableWebUi": "הפעלת נקודות קצה של האינטרנט",
"enableWebUiDesc": "פתח את ממשקי האינטרנט של מארח זה מסרגל הצד.",
"webUiAddEndpoint": "הוסף נקודת קצה",
"webUiAddress": "כְּתוֹבֶת",
"webUiOpening": "פְּתִיחָה",
"webUiBindHostDesc": "היכן שה-backend קושר את forward. השאר ריק עבור 127.0.0.1, שאליו רק מכונה שמריץ את Termix יכולה להגיע.",
"webUiBindHostExposed": "כתובת זו נגישה מעבר למכונת ה-Termix, כך שכל מי שיכול להגיע לפורט מקבל ממשק משתמש אינטרנטי זה ללא צורך בכניסה.",
"webUiLocalPort": "נמל מקומי",
"webUiLocalPortAuto": "אוֹטוֹמָטִי",
"webUiLocalPortDesc": "באיזה פורט ההעברה מאזינה. השאר ריק כדי לאפשר למערכת לבחור, או הגדר פורט כאשר יש לדעת מראש את הפורט, כגון קונטיינר שמפרסם אותו.",
"webUiNewEndpointLabel": "נקודת קצה חדשה",
"webUiLabel": "מַדבֵּקָה",
"webUiScheme": "תָכְנִית",
"webUiPort": "נָמָל",
"webUiPath": "נָתִיב",
"webUiAccess": "גִישָׁה",
"webUiAccessDirect": "יָשִׁיר",
"webUiAccessTunnel": "מנהרת SSH",
"webUiAccessTunnelUnavailable": "מנהרת SSH דורשת הפעלת SSH במארח זה ומקור חיבור של מכשיר זה.",
"webUiRender": "פתח ב",
"webUiRenderExternal": "חלון שולחן עבודה מבודד",
"webUiRenderEmbedded": "לשונית טרמיקס",
"webUiRenderEmbeddedDesc": "הטמעה היא המאמץ הטוב ביותר: חלק ממשקי האינטרנט מסרבים להיטען בתוך מסגרת, וכניסה שמסתמכת על קובצי Cookie של אותו אתר עלולה לבצע לולאה. הדפדפן החיצוני תמיד עובד.",
"webUiIgnoreCert": "אפשר אישור לא חוקי",
"webUiIgnoreCertDesc": "נקודות קצה ישירות בלבד. לולאה חוזרת כבר פטורה, כך שאין לכך השפעה על נקודות קצה של המנהרה.",
"webUiRemoveEndpoint": "הסר את נקודת הקצה",
"webUiOpenFailed": "נכשלה פתיחת נקודת הקצה של האינטרנט",
"bindHost": "מארח קשירה"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "कोई होस्ट चयनित नहीं है",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "पुनः प्रयास करें",
"reload": "पुनः लोड करें",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "बाह्य संपादक खोलें",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "पता",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "स्थानीय पोर्ट",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "लेबल",
"webUiScheme": "Scheme",
"webUiPort": "पोर्ट",
"webUiPath": "पथ",
"webUiAccess": "Access",
"webUiAccessDirect": "सीधा",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "अमान्य प्रमाणपत्र की अनुमति दें",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "बाइंड होस्ट"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nincs kiválasztva gazdagép",
"notFound": "Ez a végpont már nem létezik.",
"tunnelUnreachableFromBrowser": "Ez a végpont a Termixet futtató gépen található 127.0.0.1 címre csatlakozik, amelyet a böngésződ nem tud elérni. Állítsd be a végpont Bind Host értékét egy olyan címre, amelyen a gép válaszol, például 0.0.0.0, és adj meg neki egy fix Local Port értéket.",
"tunnelSharesSessionCookie": "Ennek az alagútnak a megnyitása a Termix munkamenetet a bújtatott szolgáltatáshoz küldené, mivel a böngésződ ugyanazon a hosztnéven érné el, amelyről a Termix is kiszolgálja. A sütik figyelmen kívül hagyják a portot, így a kettőt nem lehet megkülönböztetni. Használd a Termix asztali alkalmazást, vagy nyisd meg a Termixet a localhost címen.",
"directSharesSessionCookie": "Ennek a végpontnak a megnyitása a Termix munkamenetet ide küldené, mivel a böngésződ ugyanazon a helyen éri el, ahonnan a Termix is kiszolgálható. A sütik figyelmen kívül hagyják a portot, így egy másik port nem jelent másik identitást. Használd a Termix asztali alkalmazást, vagy szolgáltasd ki ezt a webes felhasználói felületet egy másik hostname-ről.",
"retry": "Újrapróbálkozás",
"reload": "Újratöltés",
"copyUrl": "URL másolása",
"urlCopied": "URL másolva",
"copyFailed": "Nem sikerült másolni az URL-t",
"openExternally": "Külsőleg nyitott",
"isolationUnavailable": "Ez a böngésző nem tudja elkülöníteni a webes végpontokat a Termix-munkamenettől. Használjon hitelesítő adatok nélküli iframe-támogatással rendelkező böngészőt.",
"isolationNotice": "A beágyazott oldalak elszigetelt munkamenetet használnak. A tárhelyet vagy felugró bejelentkezést igénylő oldalakhoz használjon egy elszigetelt ablakot az asztali alkalmazásban.",
"openIsolatedWindow": "Nyitott elszigetelt ablak"
},
"hosts": {
"tabWebUi": "Webes felhasználói felület",
"webUiEndpointLabel": "Végpont {{number}}",
"webUiIntegration": "Webes felhasználói felület",
"webUiEndpointsSection": "Végpontok",
"webUiNoEndpoints": "Még nincsenek webes végpontok. A Végpont hozzáadása gombbal hozhat létre egyet.",
"webUiEndpointsDisabled": "Végpontok hozzáadásához kapcsolja be a fenti webes felhasználói felületet.",
"webUiRequirementsText": "A böngésződ egy közvetlen végpontot nyit meg, így annak portjának elérhetőnek kell lennie innen. Ehelyett a Termix egy SSH alagutat nyit meg a gazdagép SSH kapcsolatán keresztül, így a portnak csak a gazdagépről kell elérhetőnek lennie.",
"enableWebUi": "Webes végpontok engedélyezése",
"enableWebUiDesc": "Nyissa meg a gazdagép webes felületeit az oldalsávból.",
"webUiAddEndpoint": "Végpont hozzáadása",
"webUiAddress": "Cím",
"webUiOpening": "Nyílás",
"webUiBindHostDesc": "Ahol a háttérrendszer a továbbító rendszert köti. Hagyja üresen a 127.0.0.1 mezőt, amelyet csak a Termixet futtató gép tud elérni.",
"webUiBindHostExposed": "Ez a cím elérhető a Termix gépen kívül is, így bárki, aki el tudja érni a portot, bejelentkezés nélkül megkapja ezt a webes felhasználói felületet.",
"webUiLocalPort": "Helyi kikötő",
"webUiLocalPortAuto": "Automatikus",
"webUiLocalPortDesc": "Melyik porton figyel a továbbító. Hagyja üresen, ha a rendszer szeretné kiválasztani, vagy állítson be egyet, ha a portot előre ismerni kell, például egy konténer esetében, amely közzéteszi azt.",
"webUiNewEndpointLabel": "Új végpont",
"webUiLabel": "Címke",
"webUiScheme": "Rendszer",
"webUiPort": "Kikötő",
"webUiPath": "Útvonal",
"webUiAccess": "Hozzáférés",
"webUiAccessDirect": "Közvetlen",
"webUiAccessTunnel": "SSH alagút",
"webUiAccessTunnelUnavailable": "Egy SSH-alagúthoz engedélyezni kell az SSH-t ezen a gazdagépen, és ennek az eszköznek a kapcsolatának forrását kell tartalmaznia.",
"webUiRender": "Nyitva tartás",
"webUiRenderExternal": "Elszigetelt asztali ablak",
"webUiRenderEmbedded": "Termix fül",
"webUiRenderEmbeddedDesc": "A beágyazás a legjobb megoldás: egyes webes felületek nem töltődnek be keretben, és az azonos webhelyen található sütikre támaszkodó bejelentkezés ciklusba kerülhet. A külső böngésző mindig működik.",
"webUiIgnoreCert": "Érvénytelen tanúsítvány engedélyezése",
"webUiIgnoreCertDesc": "Csak közvetlen végpontok. A visszacsatolás már mentesül ez alól, így ennek nincs hatása az alagút végpontjaira.",
"webUiRemoveEndpoint": "Végpont eltávolítása",
"webUiOpenFailed": "Nem sikerült megnyitni a webes végpontot",
"bindHost": "Kötőállomás"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Muat Ulang",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nessun host selezionato",
"notFound": "Questo endpoint non esiste più.",
"tunnelUnreachableFromBrowser": "Questo endpoint crea un tunnel verso 127.0.0.1 sulla macchina su cui è in esecuzione Termix, indirizzo che il tuo browser non può raggiungere. Imposta il Bind Host dell'endpoint su un indirizzo a cui la macchina risponde, ad esempio 0.0.0.0, e assegnagli una porta locale fissa.",
"tunnelSharesSessionCookie": "L'apertura di questo tunnel indirizzerebbe la tua sessione Termix al servizio tunnelizzato, poiché il tuo browser lo raggiungerebbe tramite lo stesso hostname da cui viene erogato il servizio Termix. I cookie ignorano la porta, quindi i due non possono essere distinti. Utilizza l'applicazione desktop di Termix oppure apri Termix all'indirizzo localhost.",
"directSharesSessionCookie": "L'apertura di questo endpoint invierebbe la tua sessione Termix ad esso, poiché il tuo browser lo raggiunge dallo stesso sito da cui viene erogato il servizio Termix. I cookie ignorano la porta, quindi una porta diversa non corrisponde a un'identità diversa. Utilizza l'applicazione desktop di Termix oppure esegui questa interfaccia web da un hostname diverso.",
"retry": "Riprova",
"reload": "Ricarica",
"copyUrl": "Copia URL",
"urlCopied": "URL copiato",
"copyFailed": "Impossibile copiare l'URL",
"openExternally": "Apri esternamente",
"isolationUnavailable": "Questo browser non è in grado di isolare gli endpoint web dalla sessione Termix. Utilizza un browser che supporti gli iframe senza credenziali.",
"isolationNotice": "Le pagine incorporate utilizzano una sessione isolata. Per le pagine che richiedono l'archiviazione dei dati o l'accesso tramite popup, utilizzare una finestra isolata nell'applicazione desktop.",
"openIsolatedWindow": "Apri finestra isolata"
},
"hosts": {
"tabWebUi": "Interfaccia utente web",
"webUiEndpointLabel": "Punto finale {{number}}",
"webUiIntegration": "Interfaccia utente web",
"webUiEndpointsSection": "Punti terminali",
"webUiNoEndpoints": "Nessun endpoint web ancora disponibile. Utilizza \"Aggiungi endpoint\" per crearne uno.",
"webUiEndpointsDisabled": "Attiva l'interfaccia utente web qui sopra per aggiungere gli endpoint.",
"webUiRequirementsText": "Un endpoint diretto viene aperto dal tuo browser, quindi la sua porta deve essere raggiungibile da qui. Termix, invece, apre un tunnel SSH sulla connessione SSH di questo host, quindi la porta deve essere raggiungibile solo dall'host.",
"enableWebUi": "Endpoint web abilitati",
"enableWebUiDesc": "Apri le interfacce web di questo host dalla barra laterale.",
"webUiAddEndpoint": "Aggiungi endpoint",
"webUiAddress": "Indirizzo",
"webUiOpening": "Apertura",
"webUiBindHostDesc": "Dove il backend si collega al forward. Lascia vuoto il campo 127.0.0.1, a cui può accedere solo la macchina su cui è in esecuzione Termix.",
"webUiBindHostExposed": "Questo indirizzo è raggiungibile anche al di fuori del server Termix, quindi chiunque riesca ad accedere alla porta può utilizzare questa interfaccia web senza bisogno di effettuare il login.",
"webUiLocalPort": "Porta locale",
"webUiLocalPortAuto": "Automatico",
"webUiLocalPortDesc": "Quale porta deve rimanere in ascolto il server di inoltro. Lasciare vuoto per consentire al sistema di sceglierla automaticamente, oppure specificarne una quando la porta deve essere nota in anticipo, ad esempio per un container che la pubblica.",
"webUiNewEndpointLabel": "Nuovo endpoint",
"webUiLabel": "Etichetta",
"webUiScheme": "Schema",
"webUiPort": "Porta",
"webUiPath": "Percorso",
"webUiAccess": "Accesso",
"webUiAccessDirect": "Diretta",
"webUiAccessTunnel": "Tunnel SSH",
"webUiAccessTunnelUnavailable": "Per creare un tunnel SSH è necessario che SSH sia abilitato su questo host e che l'origine della connessione sia questo dispositivo.",
"webUiRender": "Apri in",
"webUiRenderExternal": "Finestra del desktop isolata",
"webUiRenderEmbedded": "scheda Termix",
"webUiRenderEmbeddedDesc": "L'incorporamento è un tentativo quantomeno efficace: alcune interfacce web si rifiutano di caricarsi in un frame e un login che si basa sui cookie dello stesso sito potrebbe generare un loop. Il browser esterno funziona sempre.",
"webUiIgnoreCert": "Consenti certificato non valido",
"webUiIgnoreCertDesc": "Solo endpoint diretti. Il loopback è già escluso, quindi ciò non ha alcun effetto sugli endpoint del tunnel.",
"webUiRemoveEndpoint": "Rimuovi endpoint",
"webUiOpenFailed": "Impossibile aprire l'endpoint web",
"bindHost": "Host di bind"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "ホストが選択されていません",
"notFound": "このエンドポイントは既に存在しません。",
"tunnelUnreachableFromBrowser": "このエンドポイントは、Termixを実行しているマシン上の127.0.0.1にトンネル接続しますが、このアドレスにはブラウザからアクセスできません。エンドポイントのバインドホストを、そのマシンが応答するアドレス(例:0.0.0.0)に設定し、固定のローカルポートを指定してください。",
"tunnelSharesSessionCookie": "このトンネルを開くと、ブラウザがTermixと同じホスト名でアクセスするため、Termixセッションはトンネル化されたサービスに送信されます。Cookieはポート番号を無視するため、両者を区別することはできません。Termixデスクトップアプリを使用するか、localhostでTermixを開いてください。",
"directSharesSessionCookie": "このエンドポイントを開くと、ブラウザがTermixと同じサイトからアクセスするため、Termixセッションがそこに送信されます。Cookieはポート番号を無視するため、ポート番号が異なっても別のIDとはみなされません。Termixデスクトップアプリを使用するか、別のホスト名からこのWeb UIを提供してください。",
"retry": "再試行",
"reload": "再読み込み",
"copyUrl": "URLをコピー",
"urlCopied": "URLがコピーされました",
"copyFailed": "URLのコピーに失敗しました",
"openExternally": "外部エディタで開く",
"isolationUnavailable": "このブラウザでは、WebエンドポイントをTermixセッションから分離できません。認証情報不要のiframeをサポートするブラウザを使用してください。",
"isolationNotice": "埋め込みページは独立したセッションを使用します。ストレージが必要なページやポップアップによるサインインが必要な場合は、デスクトップアプリで独立したウィンドウを使用してください。",
"openIsolatedWindow": "隔離されたウィンドウを開く"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "エンドポイント {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "エンドポイント",
"webUiNoEndpoints": "まだWebエンドポイントがありません。エンドポイントを追加するには、「エンドポイントの追加」を使用してください。",
"webUiEndpointsDisabled": "エンドポイントを追加するには、上記のWeb UIをオンにしてください。",
"webUiRequirementsText": "ブラウザによって直接エンドポイントが開かれるため、そのポートはブラウザからアクセス可能である必要があります。一方、TermixはこのホストのSSH接続を介してSSHトンネルを開くため、ポートはホストからのみアクセス可能であればよいのです。",
"enableWebUi": "Webエンドポイントを有効にする",
"enableWebUiDesc": "サイドバーからこのホストのウェブインターフェースを開いてください。",
"webUiAddEndpoint": "エンドポイントを追加する",
"webUiAddress": "アドレス",
"webUiOpening": "オープニング",
"webUiBindHostDesc": "バックエンドがフォワードをバインドする場所です。Termixを実行しているマシンのみがアクセスできる127.0.0.1の場合は空白のままにしてください。",
"webUiBindHostExposed": "このアドレスはTermixマシン以外からもアクセスできるため、このポートにアクセスできる人であれば誰でもログインなしでこのWeb UIにアクセスできます。",
"webUiLocalPort": "ローカルポート",
"webUiLocalPortAuto": "自動",
"webUiLocalPortDesc": "フォワーディングがリッスンするポートを指定します。システムに選択させる場合は空欄のままにし、ポートを事前に知っておく必要がある場合(例えば、ポートを公開するコンテナなど)はポート番号を指定してください。",
"webUiNewEndpointLabel": "新しいエンドポイント",
"webUiLabel": "ラベル",
"webUiScheme": "スキーム",
"webUiPort": "ポート",
"webUiPath": "パス",
"webUiAccess": "アクセス",
"webUiAccessDirect": "直接",
"webUiAccessTunnel": "SSHトンネル",
"webUiAccessTunnelUnavailable": "SSHトンネルには、このホストでSSHが有効になっており、接続元がこのデバイスである必要があります。",
"webUiRender": "開く",
"webUiRenderExternal": "分離されたデスクトップウィンドウ",
"webUiRenderEmbedded": "テルミックス錠",
"webUiRenderEmbeddedDesc": "埋め込みはベストエフォート型です。一部のWebインターフェースはフレーム内での読み込みを拒否し、同一サイトCookieに依存するログイン処理はループする可能性があります。外部ブラウザは常に正常に動作します。",
"webUiIgnoreCert": "無効な証明書を許可",
"webUiIgnoreCertDesc": "直接エンドポイントのみが対象です。ループバックは既に対象外となっているため、トンネルエンドポイントには影響しません。",
"webUiRemoveEndpoint": "エンドポイントを削除",
"webUiOpenFailed": "ウェブエンドポイントを開くのに失敗しました",
"bindHost": "バインドホスト"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "선택된 호스트가 없습니다",
"notFound": "이 엔드포인트는 더 이상 존재하지 않습니다.",
"tunnelUnreachableFromBrowser": "이 엔드포인트는 브라우저에서 접근할 수 없는 Termix 실행 머신의 127.0.0.1 주소로 터널링합니다. 엔드포인트의 바인드 호스트를 해당 머신이 응답하는 주소(예: 0.0.0.0)로 설정하고 고정된 로컬 포트를 지정하십시오.",
"tunnelSharesSessionCookie": "이 터널을 열면 브라우저가 Termix가 제공되는 호스트 이름과 동일한 호스트 이름으로 접속하게 되므로 Termix 세션이 터널링된 서비스로 전송됩니다. 쿠키는 포트 번호를 무시하므로 둘을 구분할 수 없습니다. Termix 데스크톱 앱을 사용하거나 localhost에서 Termix를 실행하세요.",
"directSharesSessionCookie": "이 엔드포인트를 열면 브라우저가 Termix가 제공되는 동일한 사이트에서 해당 엔드포인트에 접속하기 때문에 Termix 세션이 해당 엔드포인트로 전송됩니다. 쿠키는 포트 번호를 무시하므로 다른 포트 번호를 사용한다고 해서 다른 ID를 의미하는 것은 아닙니다. Termix 데스크톱 앱을 사용하거나 다른 호스트 이름에서 이 웹 UI를 제공하십시오.",
"retry": "재시도",
"reload": "새로 고침",
"copyUrl": "URL 복사",
"urlCopied": "URL이 복사되었습니다",
"copyFailed": "URL 복사에 실패했습니다",
"openExternally": "외부 편집기로 열기",
"isolationUnavailable": "이 브라우저는 웹 엔드포인트를 Termix 세션에서 분리할 수 없습니다. 자격 증명 없이 iframe을 지원하는 브라우저를 사용하십시오.",
"isolationNotice": "내장 페이지는 격리된 세션을 사용합니다. 저장 공간이나 팝업 로그인이 필요한 페이지의 경우 데스크톱 앱에서 격리된 창을 사용하십시오.",
"openIsolatedWindow": "격리된 창을 엽니다"
},
"hosts": {
"tabWebUi": "웹 UI",
"webUiEndpointLabel": "엔드포인트 {{number}}",
"webUiIntegration": "웹 UI",
"webUiEndpointsSection": "엔드포인트",
"webUiNoEndpoints": "아직 웹 엔드포인트가 없습니다. 엔드포인트 추가 기능을 사용하여 생성하세요.",
"webUiEndpointsDisabled": "위의 웹 UI를 활성화하여 엔드포인트를 추가하세요.",
"webUiRequirementsText": "브라우저에서 직접 엔드포인트가 열리므로 해당 포트에 브라우저에서 접근할 수 있어야 합니다. Termix는 이 호스트의 SSH 연결을 통해 SSH 터널을 열므로 해당 포트는 호스트에서만 접근할 수 있으면 됩니다.",
"enableWebUi": "웹 엔드포인트를 활성화합니다.",
"enableWebUiDesc": "사이드바에서 이 호스트의 웹 인터페이스를 엽니다.",
"webUiAddEndpoint": "엔드포인트 추가",
"webUiAddress": "주소",
"webUiOpening": "열기",
"webUiBindHostDesc": "백엔드가 포워드를 바인딩하는 위치입니다. 127.0.0.1은 Termix가 실행되는 머신만 접근할 수 있으므로 비워 두십시오.",
"webUiBindHostExposed": "이 주소는 Termix 장비 외에서도 접근 가능하므로, 해당 포트에 접속할 수 있는 사람은 누구나 로그인 없이 이 웹 UI에 접속할 수 있습니다.",
"webUiLocalPort": "로컬 포트",
"webUiLocalPortAuto": "오토매틱",
"webUiLocalPortDesc": "포트 포워딩이 수신 대기할 포트를 지정합니다. 시스템에서 자동으로 선택하도록 하려면 비워 두거나, 포트를 미리 알아야 하는 컨테이너(예: 포트를 공개하는 컨테이너)의 경우 포트를 설정하십시오.",
"webUiNewEndpointLabel": "새로운 엔드포인트",
"webUiLabel": "이름",
"webUiScheme": "계획",
"webUiPort": "포트",
"webUiPath": "경로",
"webUiAccess": "입장",
"webUiAccessDirect": "직접",
"webUiAccessTunnel": "SSH 터널",
"webUiAccessTunnelUnavailable": "SSH 터널을 사용하려면 해당 호스트에서 SSH가 활성화되어 있어야 하고 연결 시작 장치가 이 장치여야 합니다.",
"webUiRender": "열기",
"webUiRenderExternal": "격리된 데스크톱 창",
"webUiRenderEmbedded": "터믹스 탭",
"webUiRenderEmbeddedDesc": "임베딩은 최선을 다하는 방식입니다. 일부 웹 인터페이스는 프레임 내에서 로드되지 않을 수 있으며, 동일 사이트 쿠키에 의존하는 로그인은 무한 루프에 빠질 수 있습니다. 외부 브라우저는 항상 정상적으로 작동합니다.",
"webUiIgnoreCert": "유효하지 않은 인증서 허용",
"webUiIgnoreCertDesc": "직접 연결 엔드포인트에만 적용됩니다. 루프백은 이미 제외되어 있으므로 터널 엔드포인트에는 영향을 미치지 않습니다.",
"webUiRemoveEndpoint": "엔드포인트를 제거합니다.",
"webUiOpenFailed": "웹 엔드포인트를 열 수 없습니다.",
"bindHost": "바인드 호스트"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Ingen vert valgt",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Prøv igjen",
"reload": "Last inn på nytt",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Lokal port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Sti",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nie wybrano hosta",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Ponów",
"reload": "Przeładuj",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Otwórz zewnętrznie",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Adres",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Port lokalny",
"webUiLocalPortAuto": "Automatycznie",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Etykieta",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Ścieżka",
"webUiAccess": "Access",
"webUiAccessDirect": "Bezpośredni",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Zezwól na nieprawidłowy certyfikat",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Host nasłuchu"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Recarregar",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Porta",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Permitir certificado inválido",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nenhum host selecionado",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Repetir",
"reload": "Recarregar",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Abrir externamente",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Endereço",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Porta local",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Rótulo",
"webUiScheme": "Scheme",
"webUiPort": "Porta",
"webUiPath": "Caminho",
"webUiAccess": "Access",
"webUiAccessDirect": "Direta",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Permitir certificado inválido",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Host de Ligação"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Nicio gazdă selectată",
"notFound": "Acest punct final nu mai există.",
"tunnelUnreachableFromBrowser": "Acest punct final face tunel către 127.0.0.1 pe mașina care rulează Termix, pe care browserul dvs. nu o poate accesa. Setați Bind Host-ul punctului final la o adresă la care mașina răspunde, cum ar fi 0.0.0.0, și atribuiți-i un Port Local fix.",
"tunnelSharesSessionCookie": "Deschiderea acestui tunel ar trimite sesiunea Termix către serviciul tunelat, deoarece browserul dvs. l-ar accesa la același nume de gazdă de pe care este servit Termix. Cookie-urile ignoră portul, deci cele două nu pot fi diferențiate. Folosește aplicația desktop Termix sau deschide Termix la localhost.",
"directSharesSessionCookie": "Deschiderea acestui endpoint ar trimite sesiunea Termix către acesta, deoarece browserul dvs. îl accesează pe același site de pe care este servit Termix. Cookie-urile ignoră portul, deci un port diferit nu reprezintă o identitate diferită. Folosiți aplicația desktop Termix sau serviți această interfață web de pe un nume de gazdă diferit.",
"retry": "Reîncercați",
"reload": "Reîncărcare",
"copyUrl": "Copiați adresa URL",
"urlCopied": "Adresa URL copiată",
"copyFailed": "Nu s-a putut copia adresa URL",
"openExternally": "Deschis la exterior",
"isolationUnavailable": "Acest browser nu poate izola endpoint-urile web de sesiunea ta Termix. Folosește un browser cu suport iframe fără credențiale.",
"isolationNotice": "Paginile încorporate utilizează o sesiune izolată. Pentru paginile care necesită spațiu de stocare sau conectare pop-up, utilizați o fereastră izolată în aplicația desktop.",
"openIsolatedWindow": "Deschideți o fereastră izolată"
},
"hosts": {
"tabWebUi": "Interfață web",
"webUiEndpointLabel": "Punct final {{number}}",
"webUiIntegration": "Interfață web",
"webUiEndpointsSection": "Puncte finale",
"webUiNoEndpoints": "Încă nu există puncte finale web. Folosește Adăugare punct final pentru a crea unul.",
"webUiEndpointsDisabled": "Activați interfața web de mai sus pentru a adăuga puncte finale.",
"webUiRequirementsText": "Un punct final direct este deschis de browserul dvs., deci portul său trebuie să fie accesibil de aici. Un tunel SSH este deschis de Termix prin conexiunea SSH a acestei gazde, deci portul trebuie să fie accesibil doar de la gazdă.",
"enableWebUi": "Activează punctele de terminare web",
"enableWebUiDesc": "Deschideți interfețele web ale acestei gazde din bara laterală.",
"webUiAddEndpoint": "Adăugați un punct final",
"webUiAddress": "Adresa",
"webUiOpening": "Deschidere",
"webUiBindHostDesc": "Unde backend-ul leagă forward-ul. Lăsați necompletat pentru 127.0.0.1, pe care doar mașina care rulează Termix îl poate accesa.",
"webUiBindHostExposed": "Această adresă este accesibilă dincolo de mașina Termix, așa că oricine poate accesa portul primește această interfață web fără a fi nevoie de autentificare.",
"webUiLocalPort": "Port local",
"webUiLocalPortAuto": "Automat",
"webUiLocalPortDesc": "Portul pe care ascultă redirecționarea. Lăsați necompletat pentru a permite sistemului să aleagă sau setați unul atunci când portul trebuie cunoscut în avans, cum ar fi un container care îl publică.",
"webUiNewEndpointLabel": "Punct final nou",
"webUiLabel": "Eticheta",
"webUiScheme": "Sistem",
"webUiPort": "Port",
"webUiPath": "Cale",
"webUiAccess": "Acces",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "Tunel SSH",
"webUiAccessTunnelUnavailable": "Un tunel SSH necesită SSH activat pe această gazdă și o origine a conexiunii pentru acest dispozitiv.",
"webUiRender": "Deschide în",
"webUiRenderExternal": "Fereastră izolată a desktopului",
"webUiRenderEmbedded": "Tabulă Termix",
"webUiRenderEmbeddedDesc": "Integrarea este cea mai bună metodă: unele interfețe web refuză să se încarce într-un cadru, iar o conectare care se bazează pe cookie-uri de pe același site se poate repeta. Browserul extern funcționează întotdeauna.",
"webUiIgnoreCert": "Permiteți certificatul nevalid",
"webUiIgnoreCertDesc": "Numai puncte finale directe. Loopback-ul este deja exceptat, deci acest lucru nu are efect asupra punctelor finale ale tunelului.",
"webUiRemoveEndpoint": "Eliminați punctul final",
"webUiOpenFailed": "Nu s-a putut deschide endpoint-ul web",
"bindHost": "Legați gazda"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Хост не выбран",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Повторить",
"reload": "Перезагрузить",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Открыть во внешнем редакторе",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Адрес",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Локальный порт",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Название",
"webUiScheme": "Scheme",
"webUiPort": "Порт",
"webUiPath": "Путь",
"webUiAccess": "Access",
"webUiAccessDirect": "Прямой",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Разрешить недействительный сертификат",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Хост для привязки"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Hiçbir host seçilmedi",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Yeniden dene",
"reload": "Yeniden yükle",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Harici Olarak Aç",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Adres",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Yerel Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Etiket",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Yol",
"webUiAccess": "Access",
"webUiAccessDirect": "Doğrudan",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Geçersiz sertifikaya izin ver",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bağlanma Ana Bilgisayarı"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "No host selected",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Retry",
"reload": "Reload",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Open externally",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Address",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Local Port",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Label",
"webUiScheme": "Scheme",
"webUiPort": "Port",
"webUiPath": "Path",
"webUiAccess": "Access",
"webUiAccessDirect": "Direct",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Allow invalid certificate",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Bind Host"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "Chưa chọn máy chủ",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "Thử lại",
"reload": "Tải lại",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "Mở bên ngoài",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "Địa chỉ",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "Cổng cục bộ",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "Nhãn",
"webUiScheme": "Scheme",
"webUiPort": "Cổng",
"webUiPath": "Đường dẫn",
"webUiAccess": "Access",
"webUiAccessDirect": "Trực tiếp",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "Cho phép chứng chỉ không hợp lệ",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "Máy chủ bind"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "未选择主机",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "重试",
"reload": "重新加载",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "在外部打开",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "地址",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "本地端口",
"webUiLocalPortAuto": "自动",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "标签",
"webUiScheme": "Scheme",
"webUiPort": "端口",
"webUiPath": "路径",
"webUiAccess": "Access",
"webUiAccessDirect": "直接",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "允许无效证书",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "绑定主机"
}
}
@@ -0,0 +1,55 @@
{
"webEndpoint": {
"noHostSelected": "未選擇主機",
"notFound": "This endpoint no longer exists.",
"tunnelUnreachableFromBrowser": "This endpoint tunnels to 127.0.0.1 on the machine running Termix, which your browser cannot reach. Set the endpoint's Bind Host to an address that machine answers on, such as 0.0.0.0, and give it a fixed Local Port.",
"tunnelSharesSessionCookie": "Opening this tunnel would send your Termix session to the tunnelled service, because your browser would reach it at the same hostname Termix is served from. Cookies ignore the port, so the two cannot be told apart. Use the Termix desktop app, or open Termix at localhost.",
"directSharesSessionCookie": "Opening this endpoint would send your Termix session to it, because your browser reaches it at the same site Termix is served from. Cookies ignore the port, so a different port is not a different identity. Use the Termix desktop app, or serve this web UI from a different hostname.",
"retry": "重試",
"reload": "重新載入",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"copyFailed": "Failed to copy URL",
"openExternally": "以外部編輯器開啟",
"isolationUnavailable": "This browser cannot isolate web endpoints from your Termix session. Use a browser with credentialless iframe support.",
"isolationNotice": "Embedded pages use an isolated session. For pages requiring storage or popup sign-in, use an isolated window in the desktop app.",
"openIsolatedWindow": "Open isolated window"
},
"hosts": {
"tabWebUi": "Web UI",
"webUiEndpointLabel": "Endpoint {{number}}",
"webUiIntegration": "Web UI",
"webUiEndpointsSection": "Endpoints",
"webUiNoEndpoints": "No web endpoints yet. Use Add endpoint to create one.",
"webUiEndpointsDisabled": "Turn on Web UI above to add endpoints.",
"webUiRequirementsText": "A direct endpoint is opened by your browser, so its port must be reachable from here. An SSH tunnel is opened by Termix over this host's SSH connection instead, so the port only has to be reachable from the host.",
"enableWebUi": "Enable web endpoints",
"enableWebUiDesc": "Open this host's web interfaces from the sidebar.",
"webUiAddEndpoint": "Add endpoint",
"webUiAddress": "位址",
"webUiOpening": "Opening",
"webUiBindHostDesc": "Where the backend binds the forward. Leave blank for 127.0.0.1, which only the machine running Termix can reach.",
"webUiBindHostExposed": "This address is reachable beyond the Termix machine, so anyone who can reach the port gets this web UI with no login.",
"webUiLocalPort": "本機連接埠",
"webUiLocalPortAuto": "Automatic",
"webUiLocalPortDesc": "Which port the forward listens on. Leave blank to let the system pick, or set one when the port has to be known in advance, such as a container that publishes it.",
"webUiNewEndpointLabel": "New endpoint",
"webUiLabel": "標籤",
"webUiScheme": "Scheme",
"webUiPort": "連接埠",
"webUiPath": "路徑",
"webUiAccess": "Access",
"webUiAccessDirect": "直接",
"webUiAccessTunnel": "SSH tunnel",
"webUiAccessTunnelUnavailable": "An SSH tunnel needs SSH enabled on this host and a connection origin of this device.",
"webUiRender": "Open in",
"webUiRenderExternal": "Isolated desktop window",
"webUiRenderEmbedded": "Termix tab",
"webUiRenderEmbeddedDesc": "Embedding is best effort: some web interfaces refuse to load in a frame, and a login that relies on same-site cookies may loop. The external browser always works.",
"webUiIgnoreCert": "允許無效的憑證",
"webUiIgnoreCertDesc": "Direct endpoints only. Loopback is already exempt, so this has no effect on tunnel endpoints.",
"webUiRemoveEndpoint": "Remove endpoint",
"webUiOpenFailed": "Failed to open the web endpoint",
"bindHost": "綁定主機"
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"id": "web-endpoint",
"name": "Web Endpoint",
"version": "1.0.0",
"description": "Open a host's web UI in a tab or a separate desktop window, directly or over an SSH tunnel.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Networking",
"icon": "Globe",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"network:serve",
"ui:surface",
"desktop:window"
],
"dependencies": {
"tunnels": "^1.0.0"
},
"requires": [
{
"service": "tunnels.access",
"versionRange": "^1.0.0"
}
],
"contributes": {
"tabs": [
{
"id": "web-endpoint",
"titleKey": "hosts.tabWebUi",
"icon": "Globe",
"openFrom": ["host-context-menu"]
}
],
"settings": {
"host": {
"enableKey": "enableWebUi",
"enableLabelKey": "hosts.enableWebUi",
"enableDescriptionKey": "hosts.enableWebUiDesc",
"fields": [
{
"key": "webUiConfig",
"type": "json",
"labelKey": "hosts.webUiEndpointsSection",
"requires": "enableWebUi",
"defaultable": false
}
]
}
},
"hostCapability": {
"key": "enableWebUi",
"labelKey": "hosts.tabWebUi",
"editorTab": "general"
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@termix-plugin/web-endpoint",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"lucide-react": "^1.33.0"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"axios": "^1.19.0",
"express": "^5.2.1",
"i18next": "^26.4.2",
"react": "^19.2.8",
"sonner": "^2.0.8"
}
}
@@ -0,0 +1,19 @@
import { serializeWebUiConfig } from "../shared/web-endpoint-config.js";
/**
* Registered as ctx.registry.provide("web-endpoint.hostImportNormalizer", ...)
* so host-bulk-routes.ts's Termix-JSON import path can validate this
* plugin's fields without importing anything from the plugin. Matches the
* shape core's PluginHostImportNormalizer type expects.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
if (raw.enableWebUi === undefined && raw.webUiConfig === undefined) {
return null;
}
return {
enableWebUi: !!raw.enableWebUi,
webUiConfig: raw.webUiConfig ? serializeWebUiConfig(raw.webUiConfig) : null,
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createWebEndpointRoutes } from "./routes.js";
import { hostImportNormalizer } from "./host-import.js";
export async function activate(ctx: PluginContext) {
const router = ctx.http.router<Router>();
router.use(createWebEndpointRoutes(ctx));
ctx.registry.provide(
"web-endpoint.hostImportNormalizer",
hostImportNormalizer,
);
ctx.disposables.add(
() =>
void ctx.registry.revoke(
"web-endpoint.hostImportNormalizer",
hostImportNormalizer,
),
);
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
export async function deactivate() {}
+301
View File
@@ -0,0 +1,301 @@
import express, { type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
parseWebUiConfig,
type WebEndpoint,
} from "../shared/web-endpoint-config.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: {
targetHost: string;
targetPort: number;
bindHost?: string;
bindPort?: number;
},
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{ bindHost: string; bindPort: number }>;
}
/**
* Reserved by the tunnels plugin for on-demand forwards: never retried, and
* the host id in it is what the tunnels plugin checks before letting anyone
* stop one by name.
*/
export function webEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return `web:${hostId}:${endpointId}`;
}
async function loadEndpoint(
ctx: PluginContext,
hostId: number,
endpointId: string,
): Promise<
{ error: { status: number; message: string } } | { endpoint: WebEndpoint }
> {
const host = await ctx.hosts.get(hostId);
if (!host) {
return {
error: { status: 403, message: "Host not found or access denied" },
};
}
// A bulk update that only flips enableWebUi off can leave stale endpoints
// in webUiConfig. The UI reads as off in that state, so a listed endpoint
// is not on its own a licence to open a tunnel.
const enabled = await ctx.settings.getHost<boolean>(hostId, "enableWebUi");
if (!enabled) {
return {
error: {
status: 400,
message: "Web endpoints are not enabled for this host",
},
};
}
// Re-normalized rather than trusted: the stored value predates any later
// tightening of the rules, and this is the value a forward or a window is
// built from.
const rawConfig = await ctx.settings.getHost(hostId, "webUiConfig");
const endpoint = parseWebUiConfig(rawConfig).endpoints.find(
(candidate) => candidate.id === endpointId,
);
if (!endpoint) {
return { error: { status: 400, message: "Web endpoint not found" } };
}
return { endpoint };
}
async function openTunnel(
ctx: PluginContext,
hostId: number,
endpointId: string,
endpoint: WebEndpoint,
): Promise<{ bindHost: string; bindPort: number }> {
const userId = ctx.currentActor();
let tunnels: TunnelsAccess | undefined;
try {
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
tunnels = undefined;
}
// A missing provider hands back an empty handle rather than throwing.
if (typeof tunnels?.forward !== "function") {
throw Object.assign(new Error("The tunnels plugin is not available"), {
status: 503,
});
}
try {
return await tunnels.forward(
hostId,
{
targetHost: "127.0.0.1",
targetPort: endpoint.port,
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port,
// with no authentication in front of it, and is only ever an
// explicit per-endpoint choice.
bindHost: endpoint.bindHost || "127.0.0.1",
// A fixed port when the endpoint names one, since a container can
// only publish ports it knows in advance. Otherwise the kernel picks.
bindPort: endpoint.localPort ?? undefined,
},
{
name: webEndpointTunnelName(hostId, endpointId),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
},
);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
ctx.log.error(
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
error instanceof Error ? error : undefined,
);
throw Object.assign(new Error(reason), { status: 502 });
}
}
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
function bracketIfIpv6(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function endpointUrl(
hostAddress: string,
endpoint: WebEndpoint,
localPort?: number,
): string {
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
if (endpoint.access === "tunnel") {
// Electron's isolated window always dials the backend's own loopback,
// exactly like the renderer's own currentTunnelHost(true) does.
return `${endpoint.scheme}://127.0.0.1:${localPort}${path}`;
}
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
}
export function createWebEndpointRoutes(ctx: PluginContext): Router {
const router = express.Router();
/**
* @openapi
* /plugin-api/web-endpoint/open:
* post:
* summary: Open a tunnel to a host's web endpoint
* description: For endpoints reached through SSH. Returns the local port the tunnel listens on.
* tags: [Web Endpoint]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [hostId, endpointId]
* properties:
* hostId: { type: integer }
* endpointId: { type: string }
* responses:
* 200: { description: The local port. }
* 400: { description: Invalid request, or the endpoint does not use a tunnel. }
* 401: { description: Not signed in. }
* 404: { description: Host or endpoint not found. }
*/
router.post("/open", async (req, res) => {
const userId = ctx.currentActor();
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const { hostId, endpointId } = req.body ?? {};
if (
!Number.isInteger(hostId) ||
hostId < 1 ||
typeof endpointId !== "string"
) {
return res.status(400).json({ error: "Invalid web endpoint request" });
}
// Deliberately NOT gated to the desktop. The forward binds wherever this
// backend runs, and the endpoint's own bindHost decides whether that is
// reachable from the browser.
const resolved = await loadEndpoint(ctx, hostId, endpointId);
if ("error" in resolved) {
return res
.status(resolved.error.status)
.json({ error: resolved.error.message });
}
if (resolved.endpoint.access !== "tunnel") {
return res
.status(400)
.json({ error: "This endpoint does not use a tunnel" });
}
try {
const handle = await openTunnel(
ctx,
hostId,
endpointId,
resolved.endpoint,
);
return res.status(200).json({ port: handle.bindPort });
} catch (error) {
const status = (error as { status?: number }).status ?? 500;
const message = error instanceof Error ? error.message : String(error);
return res.status(status).json({ error: message });
}
});
/**
* Opens the endpoint in an isolated Electron window instead of a tab.
* Every part of the target URL is resolved server-side (the host's own
* declared address for a direct endpoint, or the tunnel port this route
* just opened for a tunnel one) so ctx.desktop.openIsolatedWindow's
* capability check and audit line cover the whole decision, not just the
* final "open a window" step.
*/
/**
* @openapi
* /plugin-api/web-endpoint/open-window:
* post:
* summary: Open a host's web endpoint in a desktop window
* tags: [Web Endpoint]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [hostId, endpointId]
* properties:
* hostId: { type: integer }
* endpointId: { type: string }
* ignoreCert: { type: boolean, description: Accept a self-signed certificate for this window. }
* responses:
* 200: { description: The window opened. }
* 400: { description: Invalid request. }
* 401: { description: Not signed in. }
* 404: { description: Host not found or access denied. }
*/
router.post("/open-window", async (req, res) => {
const userId = ctx.currentActor();
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const { hostId, endpointId, ignoreCert } = req.body ?? {};
if (
!Number.isInteger(hostId) ||
hostId < 1 ||
typeof endpointId !== "string"
) {
return res.status(400).json({ error: "Invalid web endpoint request" });
}
const resolved = await loadEndpoint(ctx, hostId, endpointId);
if ("error" in resolved) {
return res
.status(resolved.error.status)
.json({ error: resolved.error.message });
}
const { endpoint } = resolved;
try {
let localPort: number | undefined;
if (endpoint.access === "tunnel") {
const handle = await openTunnel(ctx, hostId, endpointId, endpoint);
localPort = handle.bindPort;
}
const host = await ctx.hosts.get(hostId);
if (!host) {
return res
.status(403)
.json({ error: "Host not found or access denied" });
}
const url = endpointUrl(host.ip, endpoint, localPort);
const result = await ctx.desktop.openIsolatedWindow({
url,
title: endpoint.label,
ignoreCert: endpoint.access === "direct" && ignoreCert === true,
});
return res.status(200).json(result);
} catch (error) {
const status = (error as { status?: number }).status ?? 502;
const message = error instanceof Error ? error.message : String(error);
return res.status(status).json({ error: message });
}
});
return router;
}
@@ -0,0 +1,523 @@
import { useEffect, useState } from "react";
import { Globe } from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
Button,
Checkbox,
FakeSwitch,
Input,
SectionCard,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SettingRow,
} from "@termix/plugin-sdk/ui";
import { resolveConnectionOrigin } from "@termix/plugin-sdk/ui";
import { isElectron } from "@termix/plugin-sdk/ui";
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
type WebEndpoint,
} from "../shared/web-endpoint-config";
import {
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "./web-endpoint-url";
import {
MAX_WEB_ENDPOINT_PORT,
MIN_WEB_ENDPOINT_PORT,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "./web-endpoint-validation";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
/**
* crypto.randomUUID is undefined outside a secure context, so a plain-http web
* deployment -- a first-class target for the direct+external path -- would
* throw on "Add endpoint".
*/
function endpointId(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
// Only the tunnel-bind reasons render here; this preview sits inside the
// tunnel-access block. The direct cookie refusal is enforced at open time in
// WebEndpointTab / openWebEndpointExternally, where the target host is known.
const BIND_HOST_REFUSAL_MESSAGES: Record<
Exclude<WebEndpointRefusalReason, "direct-shares-session-cookie">,
string
> = {
"loopback-bind-on-remote-backend": "hosts.webUiBindHostUnreachable",
"shares-session-cookie-with-termix": "hosts.webUiBindHostSharesSessionCookie",
};
/**
* True when the bind address reaches beyond the machine running the backend. A
* forward bound there serves the target's web UI to anyone who can reach the
* port, with no authentication in front of it, so the editor says so plainly
* rather than leaving it to be discovered.
*/
function isExposedBindHost(bindHost: string | undefined): boolean {
const value = (bindHost ?? "").trim();
if (!value) return false;
return !["127.0.0.1", "localhost", "::1", "[::1]"].includes(
value.toLowerCase(),
);
}
function newEndpoint(label: string): WebEndpoint {
return {
id: endpointId(),
label,
scheme: "https",
port: 443,
path: "/",
access: "direct",
render: "embedded",
};
}
/**
* The host editor's Web UI tab. Writes into the form's pluginSettings for
* this plugin, which the editor saves through the plugin's host settings
* route after the host itself, exactly as the tunnels plugin's own section
* does.
*
* Owns the async connection-origin resolution for the tunnel gate itself
* (tunnelAvailable = enableSsh && originIsLocal), deliberately NOT gated on
* isElectron(): the forward binds wherever the backend runs, exactly as the
* tunnels plugin does, and a web deployment reaches it at the host serving
* Termix provided the endpoint opted out of a loopback bind.
*
* connectionType is passed as "ssh" deliberately: a tunnel endpoint always
* rides an SSH connection, and "ssh" keeps resolveConnectionOrigin out of its
* RDP/VNC/Telnet guacamole special case, which always resolves to "remote".
*
* Note the deliberate asymmetry with the sidebar: the sidebar entry appears on
* enableWebUi alone, because a direct endpoint needs no SSH -- but Web UI is
* an SSH sub-tab, so a host with SSH disabled cannot configure endpoints at
* all. That is the accepted behaviour, not an oversight.
*/
export function HostEditorWebUiSection({
form,
updateForm,
protocols,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const [originIsLocal, setOriginIsLocal] = useState(false);
const connectionOrigin = (form?.connectionOrigin as string | null) ?? null;
useEffect(() => {
let cancelled = false;
void resolveConnectionOrigin({
connectionOrigin: connectionOrigin as "local" | "remote" | null,
}).then((origin) => {
if (!cancelled) setOriginIsLocal(origin === "local");
});
return () => {
cancelled = true;
};
}, [connectionOrigin]);
const tunnelAvailable = protocols.enableSsh && originIsLocal;
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)?.[
"web-endpoint"
] ?? {}) as Record<string, unknown>;
const enableWebUi = settings.enableWebUi === true;
const webUiConfig = (settings.webUiConfig ?? { endpoints: [] }) as {
endpoints: WebEndpoint[];
};
const endpoints = webUiConfig.endpoints ?? [];
const setSettings = (patch: Record<string, unknown>) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
"web-endpoint": { ...(all["web-endpoint"] ?? {}), ...patch },
},
};
});
const commit = (next: WebEndpoint[]) =>
setSettings({ webUiConfig: { endpoints: next } });
const update = (index: number, patch: Partial<WebEndpoint>) =>
commit(
endpoints.map((endpoint, i) =>
i === index ? { ...endpoint, ...patch } : endpoint,
),
);
// Labels identify an endpoint in the sidebar picker, so a fresh row must not
// silently collide with one already there.
const uniqueLabel = (base: string) => {
const taken = new Set(endpoints.map((e) => e.label.trim()));
if (!taken.has(base)) return base;
for (let n = 2; ; n += 1) {
const candidate = `${base} ${n}`;
if (!taken.has(candidate)) return candidate;
}
};
const addEndpoint = () =>
commit([
...endpoints,
newEndpoint(uniqueLabel(t("hosts.webUiNewEndpointLabel"))),
]);
return (
// Two cards, as the tunnels tab does: settings first, then the list it
// governs, each with its own header.
<>
<SectionCard
title={t("hosts.webUiIntegration")}
icon={<Globe className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableWebUi")}
description={t("hosts.enableWebUiDesc")}
>
<FakeSwitch
checked={enableWebUi}
onChange={(v: boolean) => setSettings({ enableWebUi: v })}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.webUiRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.webUiEndpointsSection")}
icon={<Globe className="size-3.5" />}
action={
enableWebUi ? (
// Same header control, and the same classes, as the tunnels list.
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
disabled={endpoints.length >= MAX_WEB_ENDPOINTS}
onClick={addEndpoint}
>
{t("hosts.webUiAddEndpoint")}
</Button>
) : undefined
}
>
<div className="flex flex-col gap-3 py-3">
{!enableWebUi ? (
<p className="text-xs text-muted-foreground">
{t("hosts.webUiEndpointsDisabled")}
</p>
) : endpoints.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("hosts.webUiNoEndpoints")}
</p>
) : (
<>
{endpoints.map((endpoint, index) => {
const rowError = webEndpointRowError(endpoint);
return (
<div
key={endpoint.id}
className="flex flex-col gap-3 p-3 border border-border bg-muted/20 relative group"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.webUiEndpointLabel", { number: index + 1 })}
</span>
<Button
variant="ghost"
size="sm"
aria-label={t("hosts.webUiRemoveEndpoint")}
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
commit(endpoints.filter((_, i) => i !== index))
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLabel")}
</label>
<Input
aria-label={t("hosts.webUiLabel")}
value={endpoint.label}
onChange={(e) =>
update(index, { label: e.target.value })
}
maxLength={MAX_WEB_ENDPOINT_LABEL_LENGTH}
className="h-7 text-xs"
/>
</div>
{rowError && (
<p className="text-[11px] text-destructive">
{t(webEndpointErrorKey(rowError), { row: index + 1 })}
</p>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiAddress")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.scheme}
onValueChange={(value) =>
update(index, {
scheme: value as WebEndpoint["scheme"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiScheme")}
size="sm"
className="h-7 w-24 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="https" className="text-xs">
https
</SelectItem>
<SelectItem value="http" className="text-xs">
http
</SelectItem>
</SelectContent>
</Select>
<Input
aria-label={t("hosts.webUiPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
value={endpoint.port}
onChange={(e) => {
// Number("") is 0, which the normalizer rejects --
// so clearing the field would otherwise write an
// endpoint that is silently dropped on save.
// Commit only a value the normalizer would keep;
// anything else leaves the last good port.
const parsed = Number(e.target.value);
if (isWebEndpointPortValid(parsed)) {
update(index, { port: parsed });
}
}}
className="h-7 w-20 text-xs"
/>
<Input
aria-label={t("hosts.webUiPath")}
value={endpoint.path ?? "/"}
onChange={(e) =>
update(index, { path: e.target.value })
}
className="h-7 flex-1 text-xs"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiOpening")}
</label>
<div className="flex items-center gap-2">
<Select
value={endpoint.access}
onValueChange={(value) =>
update(index, {
access: value as WebEndpoint["access"],
// Meaningless for a tunnel, whose host component
// is loopback and already exempt.
ignoreCert:
value === "direct"
? endpoint.ignoreCert
: false,
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiAccess")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct" className="text-xs">
{t("hosts.webUiAccessDirect")}
</SelectItem>
<SelectItem
value="tunnel"
disabled={!tunnelAvailable}
>
{t("hosts.webUiAccessTunnel")}
</SelectItem>
</SelectContent>
</Select>
<Select
value={endpoint.render}
onValueChange={(value) =>
update(index, {
render: value as WebEndpoint["render"],
})
}
>
<SelectTrigger
aria-label={t("hosts.webUiRender")}
size="sm"
className="h-7 flex-1 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
value="external"
className="text-xs"
disabled={!isElectron()}
>
{t("hosts.webUiRenderExternal")}
</SelectItem>
<SelectItem value="embedded" className="text-xs">
{t("hosts.webUiRenderEmbedded")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{endpoint.access === "tunnel" && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
aria-label={t("hosts.bindHost")}
placeholder="127.0.0.1"
value={endpoint.bindHost ?? ""}
onChange={(e) =>
update(index, { bindHost: e.target.value })
}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiBindHostDesc")}
</p>
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.webUiLocalPort")}
</label>
<Input
aria-label={t("hosts.webUiLocalPort")}
type="number"
min={MIN_WEB_ENDPOINT_PORT}
max={MAX_WEB_ENDPOINT_PORT}
placeholder={t("hosts.webUiLocalPortAuto")}
value={endpoint.localPort ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
update(index, { localPort: undefined });
return;
}
const parsed = Number(raw);
if (isWebEndpointPortValid(parsed)) {
update(index, { localPort: parsed });
}
}}
className="h-7 text-xs"
/>
<p className="text-[11px] opacity-70">
{t("hosts.webUiLocalPortDesc")}
</p>
{isExposedBindHost(endpoint.bindHost) && (
<p className="text-[11px] text-destructive">
{t("hosts.webUiBindHostExposed")}
</p>
)}
{(() => {
// Said while configuring, not only when the tab fails
// to load: a loopback bind on a backend that is not
// this machine opens fine and then leaves the browser
// with nothing to connect to, and a tunnel reached at
// Termix's own hostname would hand the tunnelled
// service this session.
const refusal = webEndpointRefusalReason(
endpoint,
isElectron(),
undefined,
);
if (!refusal) return null;
// Unreachable from this tunnel-only block; narrows
// the union to the reasons this preview renders.
if (refusal === "direct-shares-session-cookie") {
return null;
}
return (
<p className="text-[11px] text-destructive">
{t(BIND_HOST_REFUSAL_MESSAGES[refusal])}
</p>
);
})()}
</div>
)}
{endpoint.render === "embedded" && (
<p className="text-[11px] opacity-70">
{t("hosts.webUiRenderEmbeddedDesc")}
</p>
)}
{endpoint.access === "direct" && (
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={endpoint.ignoreCert === true}
onCheckedChange={(value) =>
update(index, { ignoreCert: value === true })
}
/>
{t("hosts.webUiIgnoreCert")}
</label>
<p className="text-[11px] opacity-70">
{t("hosts.webUiIgnoreCertDesc")}
</p>
</div>
)}
</div>
);
})}
{!tunnelAvailable && (
// Shown whenever tunnelling is unavailable, not only once a row
// already asks for it: the Tunnel option renders disabled, and
// a disabled control with no stated reason reads as the
// dropdown being broken.
<p className="text-[11px] opacity-70">
{t("hosts.webUiAccessTunnelUnavailable")}
</p>
)}
</>
)}
</div>
</SectionCard>
</>
);
}
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Globe, RotateCw } from "lucide-react";
import { toast } from "sonner";
import {
currentTunnelHost,
resolveWebEndpointUrl,
webEndpointRefusalReason,
type WebEndpointRefusalReason,
} from "./web-endpoint-url";
import {
allowInvalidCertificateForOrigin,
openWebEndpointExternally,
openWebEndpointTunnel,
requireNumericHostId,
} from "./web-endpoint-api";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { isElectron } from "@termix/plugin-sdk/ui";
import { Button } from "@termix/plugin-sdk/ui";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
import type { WebEndpoint } from "../shared/web-endpoint-config";
const REFUSAL_MESSAGES: Record<WebEndpointRefusalReason, string> = {
"loopback-bind-on-remote-backend": "webEndpoint.tunnelUnreachableFromBrowser",
"shares-session-cookie-with-termix": "webEndpoint.tunnelSharesSessionCookie",
"direct-shares-session-cookie": "webEndpoint.directSharesSessionCookie",
};
/**
* Renders one web endpoint in an iframe.
*
* Takes the whole host plus the endpoint's ID -- not the endpoint object --
* so a tab whose endpoint was deleted while it was open (or that never matched
* one, if the host's webUiConfig raced the tab's mount) can be detected here
* and shown a plain "no longer exists" message instead of throwing on
* endpoint.scheme.
*/
export function WebEndpointTab({
host,
endpointId,
}: {
host: Host;
endpointId?: string;
}) {
const { t } = useTranslation();
const webUiConfig = (
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.["web-endpoint"]?.webUiConfig as { endpoints?: WebEndpoint[] } | undefined;
const endpoint = (webUiConfig?.endpoints ?? []).find(
(candidate) => candidate.id === endpointId,
);
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* Bumped on every resolution that lands, and folded into the iframe's key
* alongside the URL. A direct endpoint's URL never changes, and a live
* tunnel returns the SAME port on every open -- so the URL alone is not
* enough to key on: React bails out of a same-value setState, the key would
* not change, and Reload would silently do nothing in the two most common
* cases.
*/
const [generation, setGeneration] = useState(0);
/**
* Guards two in-flight resolutions completing out of order -- a
* double-clicked Reload, or a manual reload racing the mount effect. Only
* the most recently started resolution may apply its result.
*/
const resolveGenerationRef = useRef(0);
const resolve = useCallback(async () => {
if (!endpoint) return;
const mine = ++resolveGenerationRef.current;
setError(null);
try {
// Checked BEFORE the open call, not after. A tunnel must not be opened
// from a browser when a loopback bind on a remote backend would leave
// nothing to connect to, or when the tunnel URL would land on the page's
// own host string. A DIRECT endpoint on the same host that serves Termix
// leaks the session cookie exactly the same way -- cookies ignore the
// port -- so it is refused here too, before any navigation, rather than
// framing the URL and leaking on the first request.
const refusal = webEndpointRefusalReason(endpoint, isElectron(), host.ip);
if (refusal) {
throw new Error(t(REFUSAL_MESSAGES[refusal]));
}
if (!("credentialless" in HTMLIFrameElement.prototype)) {
throw new Error(t("webEndpoint.isolationUnavailable"));
}
// Always re-resolved rather than reloading the frame: the backend closes
// an idle tunnel after ten minutes and re-binds a fresh kernel-assigned
// port, so a cached port may be gone.
const localPort =
endpoint.access === "tunnel"
? await openWebEndpointTunnel(
requireNumericHostId(host.id),
endpoint.id,
)
: undefined;
const resolved = resolveWebEndpointUrl({
hostAddress: host.ip,
endpoint,
localPort,
// Non-null for a tunnel by the time we get here: a null separated host
// is exactly what the refusal above catches.
tunnelHost: currentTunnelHost(isElectron()) ?? undefined,
});
if (endpoint.access === "direct" && endpoint.ignoreCert) {
await allowInvalidCertificateForOrigin(new URL(resolved).origin);
}
if (mine !== resolveGenerationRef.current) return;
setUrl(resolved);
setGeneration((g) => g + 1);
} catch (caught) {
if (mine !== resolveGenerationRef.current) return;
setError(caught instanceof Error ? caught.message : String(caught));
}
// Depends on the endpoint's FIELDS, not the object: host.webUiConfig is
// re-derived on every render of the host list, so a dependency on
// `endpoint` itself would reopen the tunnel on every parent render.
// bindHost and localPort are included because the refusal guard reads them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
host.id,
host.ip,
endpoint?.id,
endpoint?.access,
endpoint?.scheme,
endpoint?.port,
endpoint?.path,
endpoint?.ignoreCert,
endpoint?.bindHost,
endpoint?.localPort,
]);
useEffect(() => {
void resolve();
}, [resolve]);
if (!endpoint) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs text-muted-foreground">
<Globe className="size-5 opacity-60" />
<p>{t("webEndpoint.notFound")}</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-xs">
<Globe className="size-5 opacity-60" />
<p className="text-center">{error}</p>
<Button variant="outline" size="sm" onClick={() => void resolve()}>
{t("webEndpoint.retry")}
</Button>
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-2 py-1 text-xs">
<Button
variant="ghost"
size="icon-sm"
onClick={() => void resolve()}
title={t("webEndpoint.reload")}
>
<RotateCw />
</Button>
<span className="truncate text-muted-foreground">{url ?? ""}</span>
<Button
variant="ghost"
size="xs"
className="ml-auto"
onClick={() => {
if (!url) return;
void copyToClipboard(url).then((ok) => {
if (ok) toast.success(t("webEndpoint.urlCopied"));
else toast.error(t("webEndpoint.copyFailed"));
});
}}
>
{t("webEndpoint.copyUrl")}
</Button>
{isElectron() && (
<Button
variant="ghost"
size="xs"
onClick={() => {
void openWebEndpointExternally(host, endpoint).catch((error) =>
toast.error(error.message),
);
}}
>
{t("webEndpoint.openIsolatedWindow")}
</Button>
)}
</div>
<p className="px-2 py-1 text-xs text-muted-foreground">
{t("webEndpoint.isolationNotice")}
</p>
{url && (
// Keyed on generation as well as url: a re-resolved tunnel usually
// returns the SAME port, so url alone would not change and React would
// never remount the frame.
<iframe
key={`${generation}:${url}`}
title={endpoint.label}
ref={(frame) => {
if (!frame) return;
// Set isolation before the first navigation, including redirects.
(
frame as HTMLIFrameElement & { credentialless: boolean }
).credentialless = true;
frame.src = url;
}}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
className="h-full w-full flex-1 border-0"
/>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { Globe } from "lucide-react";
import i18next from "i18next";
import { toast } from "sonner";
import type {
HostEditorSectionProps,
PluginHostRecord,
ShellApi,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
import type { WebEndpoint } from "../shared/web-endpoint-config";
import { WebEndpointTab } from "./WebEndpointTab";
import { HostEditorWebUiSection } from "./HostEditorWebUiSection";
import {
openWebEndpointExternally,
setWebEndpointApi,
} from "./web-endpoint-api";
const TAB_TYPE = "web-endpoint";
function webEndpointSettings(host: PluginHostRecord): {
enableWebUi: boolean;
webUiConfig: { endpoints: WebEndpoint[] };
} {
const settings = (
host.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.["web-endpoint"];
return {
enableWebUi: settings?.enableWebUi === true,
webUiConfig: (settings?.webUiConfig as
{ endpoints: WebEndpoint[] } | undefined) ?? {
endpoints: [],
},
};
}
function endpointsOf(host: PluginHostRecord): WebEndpoint[] {
const { enableWebUi, webUiConfig } = webEndpointSettings(host);
if (!enableWebUi) return [];
return webUiConfig.endpoints ?? [];
}
function openEndpoint(
host: PluginHostRecord,
endpoint: WebEndpoint,
shell: ShellApi,
): void {
if (endpoint.render === "external") {
// No tab at all: the real browser opens it. On the desktop the plugin
// asks the backend to open it in an isolated window.
openWebEndpointExternally(
host as unknown as { id: string; ip: string },
endpoint,
).catch((error: unknown) => {
toast.error(
error instanceof Error
? error.message
: i18next.t("web-endpoint:hosts.webUiOpenFailed"),
);
});
return;
}
shell.openTab(host, TAB_TYPE, {
label: endpoint.label,
data: { endpointId: endpoint.id },
});
}
function EndpointTab({ host, tab }: TabProps) {
// Resolved against the host on every render, so an endpoint deleted while
// its tab is open shows a plain message instead of throwing.
const endpointId = tab.data?.endpointId;
return (
<WebEndpointTab
host={host as unknown as Host}
endpointId={typeof endpointId === "string" ? endpointId : undefined}
/>
);
}
function WebUiSection(props: HostEditorSectionProps) {
return <HostEditorWebUiSection {...props} />;
}
export function activate(app: TermixApp): void {
setWebEndpointApi(app.api);
app.onDispose(() => setWebEndpointApi(null));
app.registerTab(TAB_TYPE, EndpointTab, {
icon: Globe,
titleKey: "hosts.tabWebUi",
requiresHost: true,
noHostMessageKey: "webEndpoint.noHostSelected",
// A restored tab could never hold a valid URL: the backend closes an idle
// tunnel after ten minutes and binds a fresh port on the next open. So
// these tabs are session-only and never saved.
persistent: false,
inLayouts: false,
});
// One entry per host, not one per endpoint: with several it opens a picker.
// Gated on enableWebUi alone, since a direct endpoint needs no SSH.
app.registerHostAction({
id: TAB_TYPE,
titleKey: "hosts.tabWebUi",
icon: Globe,
kind: "open",
order: 80,
when: (host) => endpointsOf(host).length > 0,
label: (host) => {
const endpoints = endpointsOf(host);
return endpoints.length === 1 ? endpoints[0].label : undefined;
},
items: (host) =>
endpointsOf(host).map((endpoint) => ({
id: endpoint.id,
label: endpoint.label,
run: (target, shell) => openEndpoint(target, endpoint, shell),
})),
});
app.registerHostEditorSection({
id: "web-ui",
group: "ssh",
titleKey: "hosts.tabWebUi",
icon: Globe,
order: 40,
component: WebUiSection,
});
}
@@ -0,0 +1,155 @@
import axios from "axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { isElectron } from "@termix/plugin-sdk/ui";
import type { WebEndpoint } from "../shared/web-endpoint-config";
let pluginApi: PluginApiClient | null = null;
function genericFailure(error: unknown, action: string): never {
if (!axios.isAxiosError(error) && error instanceof Error) throw error;
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
throw new WebEndpointTunnelError(`Could not ${action}`, status);
}
/** Set from activate with app.api, cleared on deactivate. */
export function setWebEndpointApi(api: PluginApiClient | null): void {
pluginApi = api;
}
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
* rejected, host unreachable, or nothing listening on the target port.
* Preserves that reason instead of collapsing three actionable errors into
* one generic failure.
*/
export class WebEndpointTunnelError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WebEndpointTunnelError";
}
}
/**
* Opens (or reuses) the SSH forward for a tunnel-access web endpoint and
* returns the port it is listening on. The port is NOT stable across opens:
* the backend closes an idle tunnel and re-binds a fresh kernel-assigned port,
* so callers must re-resolve rather than cache it.
*/
export async function openWebEndpointTunnel(
hostId: number,
endpointId: string,
): Promise<number> {
try {
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
// Relative to the plugin mount, /plugin-api/web-endpoint.
const response = await pluginApi.post("/open", {
hostId,
endpointId,
});
const port = (response?.data as { port?: number } | undefined)?.port;
if (!port) {
throw new Error("The endpoint tunnel returned no port");
}
return port;
} catch (error) {
if (axios.isAxiosError(error)) {
// Typed unknown, not string: the guard below is only meaningful if the
// type does not already promise what it checks.
const data = error.response?.data as
{ error?: unknown; message?: unknown } | undefined;
const backendMessage = data?.error ?? data?.message;
// A body shaped { error: <object> } would otherwise render as
// "[object Object]". Every response on this route sends a string today,
// but a proxy or a future handler need not.
if (typeof backendMessage === "string" && backendMessage) {
throw new WebEndpointTunnelError(
backendMessage,
error.response?.status,
);
}
}
return genericFailure(error, "open web endpoint tunnel");
}
}
/**
* Registers one exact origin as allowed to present an invalid TLS certificate.
* Only meaningful on the desktop, and only for direct endpoints -- a tunnel
* endpoint's host component is loopback, which the main process already
* exempts.
*/
export async function allowInvalidCertificateForOrigin(
origin: string,
): Promise<void> {
if (!isElectron()) return;
try {
await window.electronAPI?.invoke?.(
"allow-invalid-certificate-for-origin",
origin,
);
} catch {
// The user's build may predate the handler; the load then simply fails the
// certificate check as it would have before.
}
}
/**
* A web endpoint tunnel opens through the backend's numeric host id.
* Quick-connect hosts (ids like "quick-connect-<n>") have no row on the
* server, so there is nothing to open a tunnel through -- unlike a direct
* endpoint, which never touches the backend and works regardless.
*/
export function requireNumericHostId(id: string): number {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error(
"This endpoint needs an SSH tunnel, which requires a saved host",
);
}
return numericId;
}
/**
* Desktop windows use a dedicated ephemeral session, including login popups.
*
* The backend resolves and validates the target URL (the host's own declared
* address for a direct endpoint, or the tunnel port it opens for a tunnel
* one) and opens the window itself through ctx.desktop.openIsolatedWindow, so
* the capability check and audit line cover the whole decision -- this call
* only asks for it and reports whether it worked.
*/
export async function openWebEndpointExternally(
host: { id: string; ip: string },
endpoint: WebEndpoint,
): Promise<void> {
if (!isElectron()) {
throw new Error(
"Isolated windows require the desktop app. Choose Embedded in the endpoint settings.",
);
}
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
try {
await pluginApi.post("/open-window", {
hostId: requireNumericHostId(host.id),
endpointId: endpoint.id,
ignoreCert: endpoint.ignoreCert === true,
});
} catch (error) {
if (axios.isAxiosError(error)) {
const data = error.response?.data as
{ error?: unknown; message?: unknown } | undefined;
const backendMessage = data?.error ?? data?.message;
if (typeof backendMessage === "string" && backendMessage) {
throw new WebEndpointTunnelError(
backendMessage,
error.response?.status,
);
}
}
return genericFailure(error, "open isolated web endpoint");
}
}
@@ -0,0 +1,211 @@
import type { WebEndpoint } from "../shared/web-endpoint-config";
/**
* Builds the URL a web endpoint opens at.
*
* Direct access points at the host's own address. Tunnel access points at a
* port the backend has forwarded to the endpoint's port on the host -- so the
* endpoint's own port is deliberately absent from a tunnel URL.
*
* Expects an endpoint whose `path` has already been normalized by
* `normalizeWebEndpoints` (it always begins with "/"); a raw unvalidated path
* would produce a malformed URL.
*/
export function resolveWebEndpointUrl({
hostAddress,
endpoint,
localPort,
tunnelHost,
}: {
hostAddress: string;
endpoint: WebEndpoint;
localPort?: number;
/**
* Where the caller can reach the forward the backend bound. Must never be
* the host string the browser is currently reaching Termix at -- see
* `separatedTunnelHost`. Defaults to loopback.
*/
tunnelHost?: string;
}): string {
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
if (endpoint.access === "tunnel") {
if (!localPort) {
throw new Error("A tunnel endpoint needs a local port to resolve a URL");
}
const authority = bracketIfIpv6(tunnelHost?.trim() || "127.0.0.1");
return `${endpoint.scheme}://${authority}:${localPort}${path}`;
}
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
}
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
function bracketIfIpv6(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/** Host spellings that mean "this machine". */
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Each loopback spelling mapped to a DIFFERENT one that reaches the same
* machine. Being a different string is the entire point.
*/
const LOOPBACK_ALIASES: Record<string, string> = {
localhost: "127.0.0.1",
"127.0.0.1": "localhost",
"::1": "127.0.0.1",
"[::1]": "127.0.0.1",
};
/**
* A host string that reaches the same machine as `pageHost` but is a
* different key in the browser's cookie jar -- or null when none can be
* derived.
*
* This is a security mitigation, not a cosmetic choice. Cookies are keyed by
* host and IGNORE the port, and SameSite computes "site" as scheme +
* registrable domain -- also ignoring the port. So a forward reached at the
* very host string serving Termix is same-site with Termix, and two things
* follow:
*
* 1. the browser attaches Termix's `jwt` cookie to every request the framed
* third-party server sees, including its access log; and
* 2. a `Set-Cookie: jwt=...` from that server lands in the jar Termix's own
* API calls read from -- and both auth middlewares read the cookie
* BEFORE the Authorization header, so it decides who Termix acts as.
*
* Both directions were reproduced against a real SSH forward.
*
* Only loopback literals get an alias. A same-registrable-domain name would
* not be safe even though it is a different host: the target could answer
* `Set-Cookie: jwt=...; Domain=example.com`, which reaches Termix anyway.
* Loopback literals cannot carry a Domain attribute, so they are the only
* spellings that close both directions.
*/
export function separatedTunnelHost(pageHost: string): string | null {
return LOOPBACK_ALIASES[pageHost.trim().toLowerCase()] ?? null;
}
/** Lowercase, trim, drop surrounding IPv6 brackets and a single trailing dot. */
function normalizeHost(host: string): string {
let h = host.trim().toLowerCase();
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
if (h.endsWith(".")) h = h.slice(0, -1);
return h;
}
/**
* Whether a browser would attach Termix's `jwt` cookie to a request for
* `targetHost` while the page is served from `pageHost` -- or accept a
* `Set-Cookie` from `targetHost` into the jar Termix's own API reads.
*
* The web client sets `jwt` host-only (no `Domain`), so the port is irrelevant
* and the disclosure direction is exactly an equal host. The reverse direction
* -- the target answering `Set-Cookie: jwt=...; Domain=<parent>` -- reaches
* Termix whenever the two share a domain, so a parent/sub relationship counts
* too. The suffix check is anchored on a dot boundary so `eviltermix.example`
* is not treated as same-site with `termix.example`.
*
* A precise registrable-domain (eTLD+1) test would also catch sibling
* subdomains under a shared parent, but needs a public-suffix list this app
* does not bundle; the exact/parent/sub check closes the reported same-host
* case only. This is defense in depth, not cookie isolation: embedded frames
* must use credentialless plus an opaque sandbox origin, including redirects.
*/
export function sharesCookieSiteWithPage(
targetHost: string,
pageHost: string,
): boolean {
const t = normalizeHost(targetHost);
const p = normalizeHost(pageHost);
if (!t || !p) return false;
if (t === p) return true;
return t.endsWith("." + p) || p.endsWith("." + t);
}
function defaultPageHost(): string {
return typeof window === "undefined" ? "127.0.0.1" : window.location.hostname;
}
/**
* Why a web endpoint must not be opened from this client.
*
* `loopback-bind-on-remote-backend` -- the forward binds where the backend
* runs. In a browser that is the server, so a loopback bind answers only to
* the server itself: the open succeeds, the port exists, and the browser
* still gets nothing.
*
* `shares-session-cookie-with-termix` -- the tunnel URL would resolve to the
* same host string the page is served from, handing Termix's session token to
* the tunnelled service. See `separatedTunnelHost`.
*
* `direct-shares-session-cookie` -- the same leak without a tunnel: a direct
* endpoint's target host shares Termix's cookie site, so the browser attaches
* the `jwt` to it (and accepts one back). A different port is not a different
* cookie key. See `sharesCookieSiteWithPage`.
*/
export type WebEndpointRefusalReason =
| "loopback-bind-on-remote-backend"
| "shares-session-cookie-with-termix"
| "direct-shares-session-cookie";
/**
* Why a web endpoint must not be opened from this client, or null when it may.
*
* Covers both access modes. A tunnel is refused when its forward would either
* be unreachable (loopback bind on a remote backend) or resolve to the page's
* own host string (`shares-session-cookie-with-termix`). A DIRECT endpoint is
* refused when its target host shares Termix's cookie site
* (`direct-shares-session-cookie`) -- the same leak, reached without a tunnel:
* cookies ignore the port, so a UI on the very host serving Termix receives
* the session. `hostAddress` is the direct endpoint's target host and is
* ignored for tunnels.
*
* The desktop is exempt: its session is Bearer-only with no `jwt` in the jar,
* and its API base ("localhost") is already separated from where tunnels
* resolve ("127.0.0.1").
*/
export function webEndpointRefusalReason(
endpoint: Pick<WebEndpoint, "access" | "bindHost">,
runningInElectron: boolean,
hostAddress: string | undefined,
pageHost: string = defaultPageHost(),
): WebEndpointRefusalReason | null {
if (runningInElectron) return null;
if (endpoint.access === "direct") {
if (hostAddress && sharesCookieSiteWithPage(hostAddress, pageHost)) {
return "direct-shares-session-cookie";
}
return null;
}
if (endpoint.access !== "tunnel") return null;
const bindHost = (endpoint.bindHost ?? "").trim().toLowerCase();
// Reported first when both apply: the bind address is what the user has to
// change either way, and it is the more specific instruction.
if (!bindHost || LOOPBACK_HOSTS.has(bindHost)) {
return "loopback-bind-on-remote-backend";
}
if (!separatedTunnelHost(pageHost)) {
return "shares-session-cookie-with-termix";
}
return null;
}
/**
* Where this client should reach a forward the backend bound, or null when no
* host string is both reachable and safe -- in which case
* `unreachableTunnelReason` explains why and the caller must not open.
*/
export function currentTunnelHost(
runningInElectron: boolean,
pageHost: string = defaultPageHost(),
): string | null {
if (runningInElectron) return "127.0.0.1";
return separatedTunnelHost(pageHost);
}
@@ -0,0 +1,119 @@
import type { WebEndpoint } from "../shared/web-endpoint-config";
/**
* Editor-side validation for web endpoint rows.
*
* The authority on what a stored endpoint may look like is
* `normalizeWebEndpoints` in ../shared/web-endpoint-config.ts, and it DROPS
* any row it refuses rather than reporting it -- so without a check here the
* user adds a row, saves, sees no error, and finds the endpoint gone on
* reload.
*
* This deliberately does NOT restate that module's full rule set: it covers
* only the conditions a user can reach by typing, with per-field errors a
* drop/keep normalizer cannot give. tests/backend/web-endpoint-config.test.ts
* covers the normalizer; this file's own tests cover the same inputs here.
*/
export const MIN_WEB_ENDPOINT_PORT = 1;
export const MAX_WEB_ENDPOINT_PORT = 65535;
export type WebEndpointFieldError =
"label" | "duplicateLabel" | "port" | "path";
const ERROR_KEYS: Record<WebEndpointFieldError, string> = {
label: "hosts.webUiErrorLabelRequired",
duplicateLabel: "hosts.webUiErrorLabelDuplicate",
port: "hosts.webUiErrorPortRange",
path: "hosts.webUiErrorPathInvalid",
};
/**
* The i18n key for a field error. Every key takes a `row` interpolation so the
* message can say which endpoint is at fault.
*/
export function webEndpointErrorKey(error: WebEndpointFieldError): string {
return ERROR_KEYS[error];
}
export function isWebEndpointPortValid(port: unknown): boolean {
return (
typeof port === "number" &&
Number.isInteger(port) &&
port >= MIN_WEB_ENDPOINT_PORT &&
port <= MAX_WEB_ENDPOINT_PORT
);
}
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* True if the normalizer would accept this path. An empty or absent path is
* fine -- the normalizer defaults it to "/" -- and so is a path with no
* leading slash, which the normalizer coerces.
*/
export function isWebEndpointPathValid(
raw: string | undefined | null,
): boolean {
if (raw === undefined || raw === null || raw === "") return true;
if (hasControlCharacter(raw)) return false;
const path = raw.startsWith("/") ? raw : `/${raw}`;
// A protocol-relative or absolute path would send the iframe or the link
// somewhere other than the endpoint.
if (path.startsWith("//")) return false;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return false;
if (path.includes("\\")) return false;
return true;
}
/**
* The first thing wrong with one row, or null if the normalizer would keep it.
* Label is checked before port so the message points at the field the user
* most likely just left blank.
*/
export function webEndpointRowError(
endpoint: WebEndpoint,
): WebEndpointFieldError | null {
if (typeof endpoint.label !== "string" || endpoint.label.trim() === "") {
return "label";
}
if (!isWebEndpointPortValid(endpoint.port)) return "port";
if (!isWebEndpointPathValid(endpoint.path)) return "path";
return null;
}
/**
* The first row the normalizer would drop, so a save can be blocked with a
* message naming it. Returns null when every row would survive.
*
* Duplicate LABELS are reported even though the normalizer tolerates them:
* the sidebar picker shows labels, so two identical ones are indistinguishable
* to the user. Duplicate ids are generated, never typed.
*/
export function findInvalidWebEndpoint(
endpoints: WebEndpoint[] | undefined | null,
): { index: number; error: WebEndpointFieldError } | null {
if (!Array.isArray(endpoints)) return null;
const seen = new Set<string>();
for (let index = 0; index < endpoints.length; index += 1) {
const endpoint = endpoints[index];
const error = webEndpointRowError(endpoint);
if (error) return { index, error };
const label = endpoint.label.trim().toLowerCase();
if (seen.has(label)) return { index, error: "duplicateLabel" };
seen.add(label);
}
return null;
}
@@ -0,0 +1,253 @@
/**
* The plugin's own copy of what used to be core's host-web-endpoints.ts,
* now that webUiConfig lives in this plugin's host-scope settings instead of
* an ssh_data column. Same rules, same shape.
*
* Shared between backend and frontend (validation runs in both: the backend
* is the real enforcement point, the editor mirrors it for instant feedback),
* so this file has no runtime dependencies of its own.
*/
export type WebEndpointAccess = "direct" | "tunnel";
export type WebEndpointRender = "external" | "embedded";
/** One web UI a host serves, declared in this plugin's host settings. */
export interface WebEndpoint {
/**
* Stable identifier. Must NOT be derived from the port: it keys both the
* tunnel name and the tab identity, so editing a port has to leave a live
* tunnel findable under the same name.
*/
id: string;
label: string;
scheme: "http" | "https";
port: number;
/** Defaults to "/". Normalized at the storage boundary, never here. */
path?: string;
access: WebEndpointAccess;
render: WebEndpointRender;
/**
* Direct endpoints only. Allows an invalid TLS certificate for this
* endpoint's exact origin. A no-op for tunnel access, whose host component
* is loopback and therefore already exempt.
*/
ignoreCert?: boolean;
/**
* Tunnel endpoints only. Where the backend binds the forward, exactly as
* the tunnels plugin exposes it. Defaults to 127.0.0.1, reachable only from
* the machine running the backend. A web deployment runs the backend on a
* server, so reaching the forward from a browser needs an address that
* machine answers on -- which also exposes the target's web UI to anyone
* who can reach the port, with no login in front of it.
*/
bindHost?: string;
/**
* Tunnel endpoints only. Which port the forward listens on, as the tunnels
* plugin's Source Port does. Left unset the kernel picks a free one, which
* is fine when backend and browser share a machine -- but a container can
* only publish ports it knows in advance.
*/
localPort?: number;
}
export interface WebUiConfig {
endpoints: WebEndpoint[];
}
/** A host may declare at most this many web endpoints. */
export const MAX_WEB_ENDPOINTS = 16;
/** Endpoint labels are truncated to this length. */
export const MAX_WEB_ENDPOINT_LABEL_LENGTH = 64;
const SCHEMES = new Set(["http", "https"]);
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
const RENDER_VALUES = new Set(["external", "embedded"]);
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
);
}
/**
* C0 controls and DEL. Checked by codepoint rather than by regex character
* class so no escaping subtlety can let one through: a path like
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
* browser strips the control character and then follows the authority.
*/
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Coerces a path to begin with "/", or returns null to drop the endpoint.
*
* This is THE enforcement point for a value that reaches an href and an
* iframe src -- the editor's checks are UX. Anything that could redirect the
* frame away from the endpoint is rejected rather than sanitized, because
* sanitizing invites a second, subtly different implementation elsewhere.
*/
function normalizePath(raw: unknown): string | null {
if (raw === undefined || raw === null || raw === "") return "/";
if (typeof raw !== "string") return null;
if (hasControlCharacter(raw)) return null;
const path = raw.startsWith("/") ? raw : `/${raw}`;
if (path.startsWith("//")) return null;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
if (path.includes("\\")) return null;
return path;
}
/**
* A bare host literal, or null to drop the endpoint, or undefined to leave the
* field unset (the route then falls back to loopback).
*
* The value lands both in a TCP listener and in a URL authority, so nothing
* carrying a scheme, port, path or credentials may survive.
*/
function normalizeBindHost(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
if (typeof raw !== "string") return null;
const value = raw.trim();
if (!value) return undefined;
if (value.length > 253) return null;
if (hasControlCharacter(value)) return null;
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
const HOSTNAME =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
if (IPV4.test(value)) {
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
}
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
return null;
}
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
if (!raw || typeof raw !== "object") return null;
const input = raw as Record<string, unknown>;
const id = typeof input.id === "string" ? input.id.trim() : "";
if (!id) return null;
const label = typeof input.label === "string" ? input.label.trim() : "";
if (!label) return null;
const scheme = input.scheme;
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
if (!isValidPort(input.port)) return null;
const path = normalizePath(input.path);
if (path === null) return null;
const access = input.access;
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
const render = input.render;
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
const isTunnel = access === "tunnel";
const endpoint: WebEndpoint = {
id,
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
scheme: scheme as WebEndpoint["scheme"],
port: input.port,
path,
access: access as WebEndpoint["access"],
render: render as WebEndpoint["render"],
// Meaningless for a tunnel, whose host component is loopback and already
// exempt from certificate checks. Cleared rather than carried so nothing
// downstream has to re-derive that.
ignoreCert: !isTunnel && input.ignoreCert === true,
};
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
// rejected: they describe a forward that a direct endpoint never creates.
if (isTunnel) {
const bindHost = normalizeBindHost(input.bindHost);
if (bindHost === null) return null;
if (bindHost !== undefined) endpoint.bindHost = bindHost;
if (input.localPort !== undefined && input.localPort !== null) {
if (!isValidPort(input.localPort)) return null;
endpoint.localPort = input.localPort;
}
}
return endpoint;
}
/**
* Drops any endpoint it refuses rather than rejecting the whole host -- one
* bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens
* (src/frontend/web-endpoint-validation.ts).
*/
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const endpoints: WebEndpoint[] = [];
for (const candidate of raw) {
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
const endpoint = normalizeEndpoint(candidate);
if (!endpoint) continue;
if (seen.has(endpoint.id)) continue;
seen.add(endpoint.id);
endpoints.push(endpoint);
}
return endpoints;
}
/**
* Never throws. A malformed stored value yields an empty endpoint list, so a
* half-written config cannot take out the whole host listing.
*/
export function parseWebUiConfig(raw: unknown): WebUiConfig {
if (raw === undefined || raw === null) return { endpoints: [] };
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return { endpoints: [] };
}
}
if (!value || typeof value !== "object") return { endpoints: [] };
return {
endpoints: normalizeWebEndpoints(
(value as { endpoints?: unknown }).endpoints,
),
};
}
/** Null when nothing survives normalization, so the setting is cleared. */
export function serializeWebUiConfig(config: unknown): WebUiConfig | null {
const endpoints = normalizeWebEndpoints(
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
);
if (endpoints.length === 0) return null;
return { endpoints };
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
const manifest = manifestJson as unknown as PluginManifest;
let mock: MockPluginContext | null = null;
afterEach(async () => {
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
mock = null;
});
function activateWith(capabilities: string[]) {
mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities,
router: () => express.Router(),
});
return activate(mock.ctx);
}
describe("web-endpoint activate", () => {
it("mounts its router at /plugin-api/web-endpoint", async () => {
await activateWith(manifest.capabilities);
expect(mock?.httpRouters).toHaveLength(1);
});
it("registers a hostImportNormalizer and revokes it on deactivate", async () => {
await activateWith(manifest.capabilities);
expect(
typeof mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
).toBe("function");
for (const dispose of [...(mock?.disposals ?? [])].reverse())
await dispose();
expect(
mock?.ctx.registry.consume("web-endpoint.hostImportNormalizer"),
).toBeUndefined();
});
it("fails closed without network:serve", async () => {
await expect(
activateWith(manifest.capabilities.filter((c) => c !== "network:serve")),
).rejects.toThrow(/network:serve/);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
describe("hostImportNormalizer", () => {
it("returns null when the row carries neither field", () => {
expect(hostImportNormalizer({})).toBeNull();
});
it("normalizes and validates webUiConfig, dropping invalid endpoints", () => {
const result = hostImportNormalizer({
enableWebUi: true,
webUiConfig: {
endpoints: [
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
access: "direct",
render: "embedded",
},
{
id: "e2",
label: "",
scheme: "https",
port: 1,
access: "direct",
render: "embedded",
},
],
},
});
expect(result?.enableWebUi).toBe(true);
expect(result?.webUiConfig).toEqual({
endpoints: [
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
ignoreCert: false,
},
],
});
});
it("clears webUiConfig to null when enabling with no endpoints", () => {
const result = hostImportNormalizer({ enableWebUi: true });
expect(result).toEqual({ enableWebUi: true, webUiConfig: null });
});
it("normalizes enableWebUi to a boolean", () => {
expect(hostImportNormalizer({ enableWebUi: "yes" })).toEqual({
enableWebUi: true,
webUiConfig: null,
});
});
});
@@ -0,0 +1,304 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { createWebEndpointRoutes } from "../../src/backend/routes.js";
const manifest = manifestJson as unknown as PluginManifest;
const forward = vi.fn();
const openIsolatedWindow = vi.fn();
function endpoint(overrides: Record<string, unknown> = {}) {
return {
id: "e1",
label: "Proxmox",
scheme: "http",
port: 8006,
path: "/",
access: "tunnel",
render: "embedded",
...overrides,
};
}
function host(overrides: Record<string, unknown> = {}) {
return {
id: 7,
userId: "u1",
name: "nas",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
let mock: MockPluginContext;
let server: http.Server;
let baseUrl: string;
async function start(
options: {
hostOverrides?: Record<string, unknown>;
noHost?: boolean;
enableWebUi?: boolean;
endpoints?: unknown[];
capabilities?: string[];
tunnelsAvailable?: boolean;
} = {},
) {
mock = createMockCtx({
manifest,
pluginId: "web-endpoint",
actor: "u1",
capabilities: options.capabilities ?? [
"hosts:read",
"network:serve",
"ui:surface",
"desktop:window",
],
hosts: options.noHost ? [] : [host(options.hostOverrides)],
});
const hostId = (options.hostOverrides?.id as number) ?? 7;
if (options.enableWebUi !== false) {
await mock.ctx.settings.setHost(hostId, "enableWebUi", true);
await mock.ctx.settings.setHost(hostId, "webUiConfig", {
endpoints: options.endpoints ?? [endpoint()],
});
}
if (options.tunnelsAvailable !== false) {
mock.ctx.services.provide("tunnels.access", { forward });
}
const app = express();
app.use(express.json());
app.use(createWebEndpointRoutes(mock.ctx));
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
}
beforeEach(() => {
forward.mockReset();
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
openIsolatedWindow.mockReset();
openIsolatedWindow.mockResolvedValue({ success: true });
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("POST /open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
await start({ noHost: true });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(403);
expect(forward).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
await start({ enableWebUi: false });
await mock.ctx.settings.setHost(7, "webUiConfig", {
endpoints: [endpoint()],
});
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(400);
expect(forward).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
await start();
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "nope" }),
});
expect(res.status).toBe(400);
});
it("returns the port the tunnels service bound", async () => {
await start();
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(200);
expect((await res.json()).port).toBe(41234);
});
it("asks for a loopback forward under the reserved web name", async () => {
await start();
await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(forward).toHaveBeenCalledWith(
7,
{
targetHost: "127.0.0.1",
targetPort: 8006,
bindHost: "127.0.0.1",
bindPort: undefined,
},
{ name: "web:7:e1", idleTimeoutMs: 10 * 60 * 1000 },
);
});
it("passes the endpoint's own bind address and fixed port through", async () => {
await start({
endpoints: [endpoint({ bindHost: "0.0.0.0", localPort: 38080 })],
});
await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
const target = forward.mock.calls[0][1];
expect(target.bindHost).toBe("0.0.0.0");
expect(target.bindPort).toBe(38080);
});
it("reports the service's reason as a 502 instead of a dead 200", async () => {
await start();
forward.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(502);
expect(String((await res.json()).error)).toMatch(/connect failed/);
});
it("answers 503 while the tunnels plugin is not available", async () => {
await start({ tunnelsAvailable: false });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(503);
});
it("rejects a malformed request before touching settings", async () => {
await start();
const getHostSpy = vi.spyOn(mock.ctx.settings, "getHost");
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
{ hostId: 0, endpointId: "e1" },
]) {
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
expect(res.status).toBe(400);
}
expect(getHostSpy).not.toHaveBeenCalled();
});
it("refuses a direct-access endpoint", async () => {
await start({ endpoints: [endpoint({ access: "direct" })] });
const res = await fetch(`${baseUrl}/open`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(400);
});
});
describe("POST /open-window", () => {
it("refuses without desktop:window", async () => {
await start({
capabilities: ["hosts:read", "network:serve", "ui:surface"],
});
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(502);
});
it("opens a tunnel first, then the window at the resolved port", async () => {
await start();
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1" }),
});
expect(res.status).toBe(200);
expect(forward).toHaveBeenCalledOnce();
expect(mock.desktopWindows).toEqual([
{ url: "http://127.0.0.1:41234/", title: "Proxmox", ignoreCert: false },
]);
});
it("opens a direct endpoint at the host's own address without a tunnel", async () => {
await start({
endpoints: [endpoint({ access: "direct", scheme: "https", port: 443 })],
});
const res = await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
});
expect(res.status).toBe(200);
expect(forward).not.toHaveBeenCalled();
expect(mock.desktopWindows).toEqual([
{ url: "https://10.0.0.5:443/", title: "Proxmox", ignoreCert: true },
]);
});
it("ignores ignoreCert for a tunnel endpoint, whose host component is already loopback", async () => {
await start();
await fetch(`${baseUrl}/open-window`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId: 7, endpointId: "e1", ignoreCert: true }),
});
expect(mock.desktopWindows[0].ignoreCert).toBe(false);
});
});
describe("route registration", () => {
it("registers /open and /open-window", async () => {
await start();
const routes = createWebEndpointRoutes(mock.ctx);
const paths = (
routes as unknown as {
stack: Array<{ route?: { path: string; methods: { post?: boolean } } }>;
}
).stack
.filter((layer) => layer.route?.methods.post)
.map((layer) => layer.route?.path);
expect(paths).toEqual(expect.arrayContaining(["/open", "/open-window"]));
});
});
@@ -0,0 +1,273 @@
import { describe, expect, it } from "vitest";
import {
MAX_WEB_ENDPOINTS,
normalizeWebEndpoints,
parseWebUiConfig,
serializeWebUiConfig,
} from "../../src/shared/web-endpoint-config.js";
function valid(overrides: Record<string, unknown> = {}) {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
describe("normalizeWebEndpoints", () => {
it("keeps a valid endpoint", () => {
expect(normalizeWebEndpoints([valid()])).toEqual([
{
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
ignoreCert: false,
},
]);
});
it("drops an unknown or dangerous scheme", () => {
expect(normalizeWebEndpoints([valid({ scheme: "ftp" })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ scheme: "javascript" })])).toEqual(
[],
);
});
it("drops out-of-range ports", () => {
expect(normalizeWebEndpoints([valid({ port: 0 })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ port: 65536 })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ port: 8.5 })])).toEqual([]);
// A numeric string is not a number; accepting it would let "8006abc"
// through some other coercion path later.
expect(normalizeWebEndpoints([valid({ port: "8006" })])).toEqual([]);
});
it("coerces a path that does not start with a slash", () => {
expect(normalizeWebEndpoints([valid({ path: "admin" })])[0].path).toBe(
"/admin",
);
});
it("defaults a missing path to /", () => {
expect(normalizeWebEndpoints([valid({ path: undefined })])[0].path).toBe(
"/",
);
});
it("drops a path carrying a scheme or authority", () => {
expect(normalizeWebEndpoints([valid({ path: "//evil.example" })])).toEqual(
[],
);
expect(
normalizeWebEndpoints([valid({ path: "http://evil.example/x" })]),
).toEqual([]);
expect(normalizeWebEndpoints([valid({ path: "/x" })])).toHaveLength(1);
});
it("drops a path whose protocol-relative prefix hides behind a control character", () => {
// "\t//evil.example" slips a naive startsWith("//") guard: the browser
// strips the control character and follows the authority.
for (const prefix of ["\t", "\n", "\r"]) {
expect(
normalizeWebEndpoints([valid({ path: `${prefix}//evil.example` })]),
).toEqual([]);
}
});
it("drops a path containing a control character anywhere", () => {
expect(normalizeWebEndpoints([valid({ path: "/a\tb" })])).toEqual([]);
expect(
normalizeWebEndpoints([
valid({ path: `/a${String.fromCharCode(0x7f)}b` }),
]),
).toEqual([]);
});
it("keeps a path containing a plain space", () => {
expect(normalizeWebEndpoints([valid({ path: "/a b" })])[0].path).toBe(
"/a b",
);
});
it("truncates an over-long label", () => {
expect(
normalizeWebEndpoints([valid({ label: "x".repeat(200) })])[0].label,
).toHaveLength(64);
});
it("drops an endpoint with a blank label or id", () => {
expect(normalizeWebEndpoints([valid({ label: " " })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ id: "" })])).toEqual([]);
});
it("keeps only the first of a duplicate id", () => {
const result = normalizeWebEndpoints([
valid({ id: "dup", label: "first" }),
valid({ id: "dup", label: "second" }),
]);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("first");
});
it("caps the list length", () => {
const many = Array.from({ length: MAX_WEB_ENDPOINTS + 5 }, (_, i) =>
valid({ id: `e${i}` }),
);
expect(normalizeWebEndpoints(many)).toHaveLength(MAX_WEB_ENDPOINTS);
});
it("returns empty for non-array input", () => {
expect(normalizeWebEndpoints(undefined)).toEqual([]);
expect(normalizeWebEndpoints(null)).toEqual([]);
expect(normalizeWebEndpoints("nope")).toEqual([]);
expect(normalizeWebEndpoints({ endpoints: [] })).toEqual([]);
});
it("drops an unknown access or render value", () => {
expect(normalizeWebEndpoints([valid({ access: "magic" })])).toEqual([]);
expect(normalizeWebEndpoints([valid({ render: "magic" })])).toEqual([]);
});
it("clears ignoreCert for tunnel access and keeps it for direct", () => {
expect(
normalizeWebEndpoints([valid({ access: "tunnel", ignoreCert: true })])[0]
.ignoreCert,
).toBe(false);
expect(
normalizeWebEndpoints([valid({ access: "direct", ignoreCert: true })])[0]
.ignoreCert,
).toBe(true);
});
});
describe("bindHost", () => {
const tunnelRow = (bindHost?: unknown) => ({
...valid({ access: "tunnel" }),
...(bindHost === undefined ? {} : { bindHost }),
});
it("keeps a loopback bind", () => {
expect(normalizeWebEndpoints([tunnelRow("127.0.0.1")])[0].bindHost).toBe(
"127.0.0.1",
);
});
it("keeps an all-interfaces bind, which is the exposed opt-in", () => {
expect(normalizeWebEndpoints([tunnelRow("0.0.0.0")])[0].bindHost).toBe(
"0.0.0.0",
);
});
it("omits it entirely when blank, so the route falls back to loopback", () => {
expect(normalizeWebEndpoints([tunnelRow("")])[0].bindHost).toBeUndefined();
expect(normalizeWebEndpoints([tunnelRow()])[0].bindHost).toBeUndefined();
});
it("drops the endpoint when the bind address is not a bare host", () => {
// It lands in a listener AND in a URL authority, so nothing carrying a
// scheme, port, path or credentials may survive.
for (const bad of [
"http://evil.example",
"127.0.0.1:8080",
"host/path",
"user@host",
"has space",
"999.1.1.1",
"-bad.example",
42,
]) {
expect(normalizeWebEndpoints([tunnelRow(bad)])).toEqual([]);
}
});
it("accepts a bracketed IPv6 literal and a hostname", () => {
expect(normalizeWebEndpoints([tunnelRow("[::1]")])[0].bindHost).toBe(
"[::1]",
);
expect(normalizeWebEndpoints([tunnelRow("nas.local")])[0].bindHost).toBe(
"nas.local",
);
});
it("ignores it on a direct endpoint, which never binds anything", () => {
expect(
normalizeWebEndpoints([
valid({ access: "direct", bindHost: "0.0.0.0" }),
])[0].bindHost,
).toBeUndefined();
});
});
describe("localPort", () => {
const tunnelRow = (localPort?: unknown) => ({
...valid({ access: "tunnel" }),
...(localPort === undefined ? {} : { localPort }),
});
it("keeps a fixed port so a container can publish it", () => {
expect(normalizeWebEndpoints([tunnelRow(38080)])[0].localPort).toBe(38080);
});
it("omits it when unset, leaving the kernel to pick", () => {
expect(normalizeWebEndpoints([tunnelRow()])[0].localPort).toBeUndefined();
});
it("drops the endpoint for a port outside the valid range", () => {
for (const bad of [0, -1, 65536, 8.5, "38080"]) {
expect(normalizeWebEndpoints([tunnelRow(bad)])).toEqual([]);
}
});
it("ignores it on a direct endpoint, which binds nothing", () => {
expect(
normalizeWebEndpoints([valid({ access: "direct", localPort: 38080 })])[0]
.localPort,
).toBeUndefined();
});
});
describe("parseWebUiConfig", () => {
it("parses a stored JSON string", () => {
expect(
parseWebUiConfig(JSON.stringify({ endpoints: [valid()] })).endpoints,
).toHaveLength(1);
});
it("accepts an already-parsed object", () => {
expect(parseWebUiConfig({ endpoints: [valid()] }).endpoints).toHaveLength(
1,
);
});
it("returns empty endpoints for malformed JSON instead of throwing", () => {
// A half-written config must not take out the whole host listing.
expect(parseWebUiConfig("{ not json")).toEqual({ endpoints: [] });
});
it("returns empty endpoints for null and undefined", () => {
expect(parseWebUiConfig(null)).toEqual({ endpoints: [] });
expect(parseWebUiConfig(undefined)).toEqual({ endpoints: [] });
});
});
describe("serializeWebUiConfig", () => {
it("round-trips through normalization", () => {
expect(
serializeWebUiConfig({ endpoints: [valid()] })?.endpoints[0].id,
).toBe("e1");
});
it("returns null when there are no valid endpoints", () => {
expect(serializeWebUiConfig({ endpoints: [] })).toBeNull();
expect(serializeWebUiConfig(null)).toBeNull();
});
});
@@ -0,0 +1,458 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
const openWebEndpointTunnel = vi.hoisted(() => vi.fn());
const allowInvalidCertificateForOrigin = vi.hoisted(() => vi.fn());
vi.mock("../../src/frontend/web-endpoint-api", async (importOriginal) => {
// requireNumericHostId is left as the real implementation -- the "no saved
// host" case exercises the actual validation, not a re-implementation of it
// that could drift.
const actual =
await importOriginal<
typeof import("../../src/frontend/web-endpoint-api")
>();
return {
...actual,
openWebEndpointTunnel: (...args: unknown[]) =>
openWebEndpointTunnel(...args),
allowInvalidCertificateForOrigin: (...args: unknown[]) =>
allowInvalidCertificateForOrigin(...args),
};
});
const electron = vi.hoisted(() => ({ isElectron: vi.fn(() => true) }));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
...electron,
}));
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<object>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
...overrides,
};
}
function host(
overrides: Partial<Host> = {},
endpoints: WebEndpoint[] = [],
): Host {
return {
id: "7",
ip: "192.168.1.10",
pluginSettings: { "web-endpoint": { webUiConfig: { endpoints } } },
...overrides,
} as unknown as Host;
}
let pageHostname = "localhost";
const realLocation = window.location;
beforeEach(() => {
Object.defineProperty(HTMLIFrameElement.prototype, "credentialless", {
configurable: true,
writable: true,
value: false,
});
pageHostname = "localhost";
electron.isElectron.mockReturnValue(true);
Object.defineProperty(window, "location", {
configurable: true,
get: () => ({ ...realLocation, hostname: pageHostname }),
});
});
afterEach(() => {
cleanup();
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
Object.defineProperty(window, "location", {
configurable: true,
value: realLocation,
});
openWebEndpointTunnel.mockReset();
allowInvalidCertificateForOrigin.mockReset();
});
describe("WebEndpointTab", () => {
it("refuses unsupported environments before creating a tunnel or iframe", async () => {
delete (HTMLIFrameElement.prototype as { credentialless?: boolean })
.credentialless;
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await screen.findByText("webEndpoint.isolationUnavailable");
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("isolates cookie state and denies parent access, popups and top navigation", async () => {
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
const frame = (await screen.findByTitle("Proxmox")) as HTMLIFrameElement;
expect(
(frame as HTMLIFrameElement & { credentialless: boolean }).credentialless,
).toBe(true);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
expect(frame.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(screen.queryByText("webEndpoint.openExternally")).toBeNull();
});
it("renders an iframe at the direct URL", async () => {
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
it("opens the tunnel through the numeric host id and frames the loopback URL", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() => {
expect(openWebEndpointTunnel).toHaveBeenCalledWith(7, "e1");
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
);
});
});
it("registers the certificate allowance for a direct endpoint with ignoreCert", async () => {
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ ignoreCert: true })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(allowInvalidCertificateForOrigin).toHaveBeenCalledWith(
"https://192.168.1.10:8006",
),
);
});
it("reframes at the new port when a reopened tunnel binds a different one", async () => {
openWebEndpointTunnel
.mockResolvedValueOnce(41234)
.mockResolvedValueOnce(51234);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:51234/",
),
);
expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2);
});
/**
* The case the port-changed test above CANNOT exercise, and the one that
* matters most: a live tunnel returns the SAME port on every open, and a
* direct endpoint's URL never changes at all. If reload just did
* setUrl(resolved), React would bail out of the same-value setState and the
* frame would never remount -- so the button would do nothing in the two
* most common cases. Capturing the DOM node before and after is what catches
* that: a same-value setState never touches the DOM.
*/
it("remounts the frame on reload even when the resolved URL is unchanged", async () => {
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
const firstFrame = await screen.findByTitle("Proxmox");
expect(firstFrame).toHaveAttribute("src", "https://127.0.0.1:41234/");
fireEvent.click(screen.getByTitle("webEndpoint.reload"));
await waitFor(() => expect(openWebEndpointTunnel).toHaveBeenCalledTimes(2));
// Polled rather than asserted once: against the defective implementation
// this never becomes true and the test times out -- a real failure, not a
// race.
await waitFor(() => {
expect(screen.getByTitle("Proxmox")).not.toBe(firstFrame);
});
});
it("shows the failure reason when the tunnel cannot open", async () => {
openWebEndpointTunnel.mockRejectedValue(
new Error("Timed out reaching the endpoint port"),
);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText(/Timed out reaching the endpoint port/),
).toBeInTheDocument(),
);
});
it("shows a plain message when the endpoint id matches nothing on the host", async () => {
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [])} endpointId="deleted" />);
await waitFor(() =>
expect(screen.getByText("webEndpoint.notFound")).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("explains that a tunnel needs a saved host, instead of calling the route", async () => {
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByText(/requires a saved host/)).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("resolves a direct endpoint normally on the same quick-connect host", async () => {
// The positive case: requireNumericHostId must be consulted only on the
// tunnel branch, or direct endpoints would break on unsaved hosts too.
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ id: "quick-connect-1" }, [endpoint({ access: "direct" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
});
it("frames a tunnel at the OTHER loopback spelling in a web deployment", async () => {
// jsdom serves the page at "localhost". The forward binds where the
// backend runs, so the browser must reach it on this same machine -- but
// NOT at the same host string, or the tunnelled service receives Termix's
// session cookie.
electron.isElectron.mockReturnValue(false);
openWebEndpointTunnel.mockResolvedValue(41234);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://127.0.0.1:41234/",
),
);
// The assertion carrying the security property: whatever host the frame
// lands on, it must not be the one serving Termix.
const framed = new URL(
screen.getByTitle("Proxmox").getAttribute("src") as string,
).hostname;
expect(framed).not.toBe(window.location.hostname);
});
it("refuses a loopback-bound tunnel in a browser instead of framing a dead port", async () => {
electron.isElectron.mockReturnValue(false);
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelUnreachableFromBrowser"),
).toBeInTheDocument(),
);
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses when the page host has no separate loopback spelling", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({}, [endpoint({ access: "tunnel", bindHost: "0.0.0.0" })])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.tunnelSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after opening would still have bound the port and still have
// leaked on the first frame load.
expect(openWebEndpointTunnel).not.toHaveBeenCalled();
expect(document.querySelector("iframe")).toBeNull();
});
/**
* The leak the tunnel guard did not cover: a DIRECT endpoint points at the
* host's own address, so when that host is the one serving Termix the frame
* is same-site with Termix and the browser attaches the `jwt` cookie. A
* different port is not a different cookie key.
*/
it("refuses a direct endpoint on the same host that serves Termix", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example";
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
// Refusing after framing would have leaked on the very first request.
expect(document.querySelector("iframe")).toBeNull();
});
it("refuses a direct endpoint on a sub domain of the page host", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "ui.termix.example.com" }, [
endpoint({ access: "direct" }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(
screen.getByText("webEndpoint.directSharesSessionCookie"),
).toBeInTheDocument(),
);
expect(document.querySelector("iframe")).toBeNull();
});
it("still frames a direct same-host endpoint on the desktop, whose jar has no jwt", async () => {
electron.isElectron.mockReturnValue(true);
pageHostname = "termix.example";
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(
<WebEndpointTab
host={host({ ip: "termix.example" }, [
endpoint({ access: "direct", port: 8443 }),
])}
endpointId="e1"
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://termix.example:8443/",
),
);
});
it("still frames a DIRECT endpoint on a hostname that refuses tunnels", async () => {
electron.isElectron.mockReturnValue(false);
pageHostname = "termix.example.com";
const { WebEndpointTab } =
await import("../../src/frontend/WebEndpointTab");
render(<WebEndpointTab host={host({}, [endpoint()])} endpointId="e1" />);
await waitFor(() =>
expect(screen.getByTitle("Proxmox")).toHaveAttribute(
"src",
"https://192.168.1.10:8006/",
),
);
});
});
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginHostRecord as Host } from "@termix/plugin-sdk/frontend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
...overrides,
};
}
function host(
endpoints: WebEndpoint[] | undefined,
overrides: Partial<Host> = {},
): Host {
return {
id: "7",
ip: "10.0.0.5",
name: "nas",
enableSsh: false,
pluginSettings:
endpoints === undefined
? {}
: { "web-endpoint": { enableWebUi: true, webUiConfig: { endpoints } } },
...overrides,
} as unknown as Host;
}
let rendered: RenderedPluginApp | null = null;
async function webAction(target: Host) {
rendered ??= await renderWithApp(plugin, { manifest });
return rendered.registered
.hostActionsFor(target)
.find((action) => action.id === "web-endpoint");
}
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("web-endpoint activate", () => {
it("registers its tab, host action and editor section", async () => {
rendered = await renderWithApp(plugin, { manifest });
expect(rendered.registered.tabs()).toEqual(["web-endpoint"]);
expect(rendered.registered.hostEditorSections()).toEqual(["web-ui"]);
});
it("offers no Web UI entry when the feature is off, even with endpoints", async () => {
const target = host([endpoint()], {
pluginSettings: {
"web-endpoint": {
enableWebUi: false,
webUiConfig: { endpoints: [endpoint()] },
},
},
});
expect(await webAction(target)).toBeUndefined();
});
it("offers no entry when enabled but no endpoints exist", async () => {
expect(await webAction(host([]))).toBeUndefined();
});
it("offers no entry when the host has no web-endpoint settings at all", async () => {
expect(await webAction(host(undefined))).toBeUndefined();
});
it("appears without SSH", async () => {
const action = await webAction(host([endpoint()], { enableSsh: false }));
expect(action).toBeDefined();
});
it("labels the entry with the endpoint when there is only one", async () => {
const target = host([endpoint({ label: "Proxmox" })]);
const action = await webAction(target);
expect(action?.label?.(target)).toBe("Proxmox");
expect(action?.items?.(target).map((item) => item.id)).toEqual(["e1"]);
});
it("stays one entry with a picker when there are several", async () => {
const endpoints = Array.from({ length: 16 }, (_, i) =>
endpoint({ id: `e${i}`, label: `Endpoint ${i}` }),
);
const target = host(endpoints);
const action = await webAction(target);
expect(action?.label?.(target)).toBeUndefined();
expect(action?.items?.(target)).toHaveLength(16);
});
it("opens an embedded endpoint as a tab carrying its id", async () => {
const target = host([endpoint()]);
const action = await webAction(target);
const calls: unknown[][] = [];
const shell = {
openTab: (...args: unknown[]) => calls.push(args),
} as never;
action!.items!(target)[0].run(target, shell);
expect(calls).toEqual([
[
target,
"web-endpoint",
{ label: "Proxmox", data: { endpointId: "e1" } },
],
]);
});
});
describe("host editor section", () => {
it("writes into the form's web-endpoint plugin settings", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
let form: Record<string, unknown> = {
name: "nas",
pluginSettings: { "web-endpoint": { enableWebUi: false } },
};
const updateForm = vi.fn(
(
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
form = patch(form);
},
);
const section = rendered.renderHostEditorSection("web-ui", {
form,
setField: vi.fn(),
updateForm,
protocols: { enableSsh: true },
});
// The enable switch is the only button on the section before any
// endpoint rows exist (which only render once enabled).
fireEvent.click(section.querySelector("button") as HTMLButtonElement);
const settings = (form.pluginSettings as Record<string, unknown>)[
"web-endpoint"
] as { enableWebUi: boolean };
expect(settings.enableWebUi).toBe(true);
});
});
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
const isElectron = vi.hoisted(() => vi.fn(() => false));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
isElectron,
}));
const pluginPost = vi.hoisted(() => vi.fn());
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "tunnel",
render: "external",
bindHost: "0.0.0.0",
...overrides,
};
}
const host = { id: "7", ip: "192.168.1.10" };
beforeEach(async () => {
const { setWebEndpointApi } =
await import("../../src/frontend/web-endpoint-api");
setWebEndpointApi({ post: pluginPost } as never);
isElectron.mockReturnValue(false);
pluginPost.mockReset();
pluginPost.mockResolvedValue({ data: { port: 41234 } });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("openWebEndpointTunnel", () => {
it("posts to the plugin's own /open route", async () => {
// app.api is already rooted at /plugin-api/web-endpoint, so the path is
// relative to that and must not repeat the prefix.
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(pluginPost).toHaveBeenCalledWith("/open", {
hostId: 7,
endpointId: "e1",
});
});
it("returns the port", async () => {
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).resolves.toBe(41234);
});
it("preserves the backend's reason instead of the generic message", async () => {
// 502 is this route's likeliest real failure and carries the actionable
// cause. Collapsing it into "Server error occurred" defeats the
// per-failure-mode messaging entirely.
const axiosError = Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: {
status: 502,
data: { error: "Timed out reaching the endpoint port" },
},
});
pluginPost.mockRejectedValue(axiosError);
const { openWebEndpointTunnel, WebEndpointTunnelError } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Timed out reaching the endpoint port/,
);
await expect(openWebEndpointTunnel(7, "e1")).rejects.toBeInstanceOf(
WebEndpointTunnelError,
);
});
it("falls back to the shared handler when the body carries no string reason", async () => {
// A body shaped { error: <object> } would otherwise render as
// "[object Object]" to the user.
pluginPost.mockRejectedValue(
Object.assign(new Error("boom"), {
isAxiosError: true,
response: { status: 500, data: { error: { nested: true } } },
}),
);
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(
/Could not open web endpoint tunnel/,
);
});
it("treats a missing port as a failure rather than returning undefined", async () => {
pluginPost.mockResolvedValue({ data: {} });
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointTunnel(7, "e1")).rejects.toThrow(/no port/);
});
});
describe("requireNumericHostId", () => {
it("accepts a saved host id and rejects a quick-connect one", async () => {
const { requireNumericHostId } =
await import("../../src/frontend/web-endpoint-api");
expect(requireNumericHostId("7")).toBe(7);
for (const bad of ["quick-connect-1", "", "0", "-3", "abc"]) {
expect(() => requireNumericHostId(bad)).toThrow(/saved host/);
}
});
});
/**
* The backend now resolves and validates the target URL itself (host address,
* tunnel port, loopback/session-cookie checks) before ever calling
* ctx.desktop.openIsolatedWindow, so this call is a thin ask-and-report over
* the plugin's own /open-window route rather than a client-side URL builder.
*/
describe("openWebEndpointExternally", () => {
it("refuses outside the desktop app before calling the backend", async () => {
const { openWebEndpointExternally } =
await import("../../src/frontend/web-endpoint-api");
await expect(openWebEndpointExternally(host, endpoint())).rejects.toThrow(
/desktop app/,
);
expect(pluginPost).not.toHaveBeenCalled();
});
it("posts hostId, endpointId and ignoreCert to /open-window", async () => {
isElectron.mockReturnValue(true);
pluginPost.mockResolvedValue({ data: { success: true } });
const { openWebEndpointExternally } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointExternally(host, endpoint({ ignoreCert: true }));
expect(pluginPost).toHaveBeenCalledWith("/open-window", {
hostId: 7,
endpointId: "e1",
ignoreCert: true,
});
});
it("preserves the backend's reason instead of the generic message", async () => {
isElectron.mockReturnValue(true);
const axiosError = Object.assign(new Error("Request failed"), {
isAxiosError: true,
response: { status: 502, data: { error: "No window to attach to" } },
});
pluginPost.mockRejectedValue(axiosError);
const { openWebEndpointExternally, WebEndpointTunnelError } =
await import("../../src/frontend/web-endpoint-api");
await expect(
openWebEndpointExternally(host, endpoint()),
).rejects.toBeInstanceOf(WebEndpointTunnelError);
});
});
@@ -0,0 +1,367 @@
import { describe, expect, it } from "vitest";
import {
currentTunnelHost,
resolveWebEndpointUrl,
separatedTunnelHost,
sharesCookieSiteWithPage,
webEndpointRefusalReason,
} from "../../src/frontend/web-endpoint-url";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
access: "direct",
render: "external",
...overrides,
};
}
describe("resolveWebEndpointUrl", () => {
it("builds a direct URL at the host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint(),
}),
).toBe("https://192.168.1.10:8006/");
});
it("builds a direct URL for a hostname", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "nas.local",
endpoint: endpoint({ port: 443 }),
}),
).toBe("https://nas.local:443/");
});
it("uses the local port for tunnel access, dropping the endpoint's own port", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "192.168.1.10",
endpoint: endpoint({ access: "tunnel", scheme: "http", port: 8006 }),
localPort: 41234,
}),
).toBe("http://127.0.0.1:41234/");
});
it("defaults a missing path to /", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ scheme: "http", port: 80, path: undefined }),
}),
).toBe("http://10.0.0.5:80/");
});
it("keeps an explicit path and its query", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({
scheme: "http",
port: 9000,
path: "/admin?tab=1",
}),
}),
).toBe("http://10.0.0.5:9000/admin?tab=1");
});
it("throws when tunnel access has no local port", () => {
expect(() =>
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel" }),
}),
).toThrow(/local port/i);
});
it("brackets a bare IPv6 host address", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "fd00::1",
endpoint: endpoint({ scheme: "http", port: 8080 }),
}),
).toBe("http://[fd00::1]:8080/");
});
it("brackets a bare IPv6 tunnel host", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "fd00::1",
}),
).toBe("http://[fd00::1]:41234/");
});
it("uses an explicit tunnel host when given", () => {
expect(
resolveWebEndpointUrl({
hostAddress: "10.0.0.5",
endpoint: endpoint({ access: "tunnel", scheme: "http" }),
localPort: 41234,
tunnelHost: "termix.lan",
}),
).toBe("http://termix.lan:41234/");
});
});
/**
* Cookies are keyed by host and IGNORE the port, and SameSite computes "site"
* as scheme + registrable domain -- also ignoring the port. So a forward
* reached at the very host string serving Termix is same-site with Termix:
* the browser attaches the jwt cookie to every request the framed
* third-party server receives, and a Set-Cookie from that server lands in the
* jar Termix's own API calls read from (both auth middlewares prefer the
* cookie over the Authorization header).
*
* Both directions were reproduced against a real SSH forward. separatedTunnelHost
* is the whole mitigation: a host string that reaches the same machine but is
* a DIFFERENT cookie-jar key.
*/
describe("separatedTunnelHost", () => {
it("maps each loopback spelling to a different one", () => {
expect(separatedTunnelHost("localhost")).toBe("127.0.0.1");
expect(separatedTunnelHost("127.0.0.1")).toBe("localhost");
expect(separatedTunnelHost("::1")).toBe("127.0.0.1");
expect(separatedTunnelHost("[::1]")).toBe("127.0.0.1");
});
it("never returns the host it was given, which would be no separation", () => {
for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
expect(separatedTunnelHost(host)).not.toBe(host);
}
});
it("is case- and whitespace-insensitive", () => {
expect(separatedTunnelHost(" LOCALHOST ")).toBe("127.0.0.1");
});
it("returns null for a host with no alias, rather than guessing", () => {
// A same-registrable-domain alias would NOT be safe: the target could
// answer Set-Cookie with Domain=example.com and reach Termix anyway.
// Only loopback literals qualify -- they cannot carry a Domain.
expect(separatedTunnelHost("termix.example.com")).toBeNull();
expect(separatedTunnelHost("192.168.1.50")).toBeNull();
expect(separatedTunnelHost("")).toBeNull();
});
});
describe("currentTunnelHost", () => {
it("is loopback on the desktop, whose API base is localhost", () => {
// Two different cookie-jar keys. Do not "simplify" either to match.
expect(currentTunnelHost(true)).toBe("127.0.0.1");
});
it("swaps to the other loopback spelling when Termix is served at localhost", () => {
expect(currentTunnelHost(false, "localhost")).toBe("127.0.0.1");
});
it("swaps the other way when Termix is served at 127.0.0.1", () => {
expect(currentTunnelHost(false, "127.0.0.1")).toBe("localhost");
});
it("has no answer for a page host with no loopback alias", () => {
expect(currentTunnelHost(false, "termix.example.com")).toBeNull();
expect(currentTunnelHost(false, "192.168.1.50")).toBeNull();
});
});
/**
* The cookie leak is not tunnel-only. A DIRECT endpoint points at the host's
* own address, and when that host is the same one serving Termix in a browser
* (a different port is not a different cookie key), the framed third-party
* service receives Termix's `jwt` -- and can set one back. So the refusal has
* to cover direct access too, using the endpoint's target host rather than a
* bind address.
*/
describe("sharesCookieSiteWithPage", () => {
it("is true for the exact same host, because cookies ignore the port", () => {
expect(sharesCookieSiteWithPage("termix.example", "termix.example")).toBe(
true,
);
});
it("is true for a parent or sub domain, which a Domain-scoped cookie crosses", () => {
expect(
sharesCookieSiteWithPage("app.termix.example", "termix.example"),
).toBe(true);
expect(
sharesCookieSiteWithPage("termix.example", "app.termix.example"),
).toBe(true);
});
it("is false for a genuinely different host", () => {
expect(sharesCookieSiteWithPage("192.168.1.10", "termix.example")).toBe(
false,
);
expect(sharesCookieSiteWithPage("nas.local", "termix.example")).toBe(false);
});
it("is case- and whitespace-insensitive and ignores a trailing dot", () => {
expect(
sharesCookieSiteWithPage(" TERMIX.example. ", "termix.example"),
).toBe(true);
});
it("does not treat a shared suffix off a label boundary as same-site", () => {
// "eviltermix.example" ends with "termix.example" as a string but not on a
// dot boundary -- treating it as same-site would let an attacker pick such
// a name to dodge the guard, so it must be false.
expect(
sharesCookieSiteWithPage("eviltermix.example", "termix.example"),
).toBe(false);
});
it("is false for blank input rather than guessing", () => {
expect(sharesCookieSiteWithPage("", "termix.example")).toBe(false);
expect(sharesCookieSiteWithPage("termix.example", "")).toBe(false);
});
});
describe("webEndpointRefusalReason", () => {
const tunnel = (bindHost?: string) =>
endpoint({ access: "tunnel", bindHost }) as Pick<
WebEndpoint,
"access" | "bindHost"
>;
const direct = () =>
endpoint({ access: "direct" }) as Pick<WebEndpoint, "access" | "bindHost">;
it("refuses a direct endpoint on the same host that serves Termix", () => {
// https://termix.example:8443 receives Termix's jwt: same host, and the
// port is not part of the cookie key.
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example",
"termix.example",
),
).toBe("direct-shares-session-cookie");
});
it("refuses a direct endpoint on a parent or sub domain of the page host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"termix.example.com",
"app.termix.example.com",
),
).toBe("direct-shares-session-cookie");
expect(
webEndpointRefusalReason(
direct(),
false,
"app.termix.example.com",
"termix.example.com",
),
).toBe("direct-shares-session-cookie");
});
it("allows a direct endpoint on a genuinely different host", () => {
expect(
webEndpointRefusalReason(
direct(),
false,
"192.168.1.10",
"termix.example",
),
).toBeNull();
expect(
webEndpointRefusalReason(direct(), false, "nas.local", "termix.example"),
).toBeNull();
});
it("allows a direct same-host endpoint on the desktop, whose jar has no jwt", () => {
expect(
webEndpointRefusalReason(
direct(),
true,
"termix.example",
"termix.example",
),
).toBeNull();
});
it("passes anything on the desktop", () => {
expect(
webEndpointRefusalReason(tunnel(), true, undefined, "localhost"),
).toBeNull();
expect(
webEndpointRefusalReason(tunnel("0.0.0.0"), true, undefined, "localhost"),
).toBeNull();
});
it("refuses a loopback bind a remote backend would strand", () => {
expect(
webEndpointRefusalReason(tunnel(), false, undefined, "localhost"),
).toBe("loopback-bind-on-remote-backend");
expect(
webEndpointRefusalReason(
tunnel("127.0.0.1"),
false,
undefined,
"localhost",
),
).toBe("loopback-bind-on-remote-backend");
});
it("allows an exposed bind when the page host has a loopback alias", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"localhost",
),
).toBeNull();
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"127.0.0.1",
),
).toBeNull();
});
it("refuses when the tunnel URL would share Termix's cookie jar", () => {
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"termix.example.com",
),
).toBe("shares-session-cookie-with-termix");
expect(
webEndpointRefusalReason(
tunnel("0.0.0.0"),
false,
undefined,
"192.168.1.50",
),
).toBe("shares-session-cookie-with-termix");
});
it("reports the reachability problem first when both apply", () => {
// The bind address is what the user has to change either way, and it is
// the more specific instruction.
expect(
webEndpointRefusalReason(
tunnel(),
false,
undefined,
"termix.example.com",
),
).toBe("loopback-bind-on-remote-backend");
});
});
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
findInvalidWebEndpoint,
isWebEndpointPathValid,
isWebEndpointPortValid,
webEndpointErrorKey,
webEndpointRowError,
} from "../../src/frontend/web-endpoint-validation";
import { normalizeWebEndpoints } from "../../src/shared/web-endpoint-config";
import type { WebEndpoint } from "../../src/shared/web-endpoint-config";
function endpoint(overrides: Partial<WebEndpoint> = {}): WebEndpoint {
return {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "external",
...overrides,
};
}
describe("isWebEndpointPortValid", () => {
it("accepts the whole legal range and nothing else", () => {
expect(isWebEndpointPortValid(1)).toBe(true);
expect(isWebEndpointPortValid(65535)).toBe(true);
for (const bad of [0, -1, 65536, 8.5, "8006", null, undefined, NaN]) {
expect(isWebEndpointPortValid(bad)).toBe(false);
}
});
});
describe("webEndpointRowError", () => {
it("returns null for a row the normalizer would keep", () => {
expect(webEndpointRowError(endpoint())).toBeNull();
});
it("reports a blank label before anything else", () => {
// Both label and port are wrong; the label is the likelier oversight.
expect(webEndpointRowError(endpoint({ label: " ", port: 0 }))).toBe(
"label",
);
});
it("reports a bad port and a bad path", () => {
expect(webEndpointRowError(endpoint({ port: 0 }))).toBe("port");
expect(webEndpointRowError(endpoint({ path: "//evil.example" }))).toBe(
"path",
);
});
});
describe("findInvalidWebEndpoint", () => {
it("returns null when every row would survive", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "NAS" }),
]),
).toBeNull();
});
it("names the offending row by index", () => {
expect(
findInvalidWebEndpoint([endpoint(), endpoint({ id: "e2", label: "" })]),
).toEqual({ index: 1, error: "label" });
});
it("reports duplicate labels, which the picker cannot tell apart", () => {
expect(
findInvalidWebEndpoint([
endpoint(),
endpoint({ id: "e2", label: "proxmox" }),
]),
).toEqual({ index: 1, error: "duplicateLabel" });
});
it("tolerates a missing list", () => {
expect(findInvalidWebEndpoint(undefined)).toBeNull();
expect(findInvalidWebEndpoint(null)).toBeNull();
});
});
describe("webEndpointErrorKey", () => {
it("maps every error to a distinct i18n key", () => {
const keys = (["label", "duplicateLabel", "port", "path"] as const).map(
webEndpointErrorKey,
);
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) expect(key).toMatch(/^hosts\./);
});
});
/**
* The editor validator and the backend normalizer are two implementations of
* overlapping rules, in two layers that cannot share code (the renderer does
* not import backend route modules). Nothing but this describe block stops
* them drifting -- and drift here is invisible in the worst way: the editor
* accepts a row, the normalizer silently drops it, and the user's endpoint
* disappears on reload with no error anywhere.
*/
describe("the editor agrees with the normalizer", () => {
const PATHS = [
"/",
"",
"/admin",
"admin",
"/admin?tab=1",
"/a b",
"//evil.example",
"http://evil.example/x",
"/x:y",
"\t//evil.example",
"\n//evil.example",
"/a\tb",
`/a${String.fromCharCode(0x7f)}b`,
"/back\\slash",
];
it.each(PATHS)("agrees on path %j", (path) => {
const editorAccepts = isWebEndpointPathValid(path);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), path }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
const PORTS = [1, 80, 8006, 65535, 0, -1, 65536, 8.5];
it.each(PORTS)("agrees on port %j", (port) => {
const editorAccepts = isWebEndpointPortValid(port);
const normalizerKeeps =
normalizeWebEndpoints([{ ...endpoint(), port }]).length === 1;
expect(editorAccepts).toBe(normalizerKeeps);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);