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.
Adds SSH port forwarding between servers, with local, remote and SOCKS5 tunnels that can start on boot. The desktop app can also open local tunnels.
+35
View File
@@ -0,0 +1,35 @@
# Tunnels
SSH port forwarding between servers, plus local tunnels from the desktop app.
## Features
- Local, remote and dynamic (SOCKS5) SSH tunnels saved on a host.
- Start tunnels from the Tunnels tab or the host editor, or on boot.
- Route a tunnel through a second host.
- Local tunnels from the desktop app, with saved presets.
- Adds a tunnels widget to the homepage.
## Settings
### Host
- Enable tunnels and the list of saved tunnels for this host.
## Permissions
- `tunnels.use`: Use tunnels. Admins and users have it by default.
## Services
Provides to other plugins:
- `tunnels.access`: open a tunnel on demand, or start, stop and check saved tunnels.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+175
View File
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Voeg tonnel by",
"autoStartDesc": "Koppel outomaties hierdie tonnel wanneer die gasheer gelaai is",
"autoStartLabel": "Outomatiese begin",
"connectBtn": "Verbind",
"disconnectBtn": "Ontkoppel",
"enableTunneling": "Aktiveer Tonnelbou",
"enableTunnelingDesc": "Aktiveer SSH-tonnelfunksionaliteit vir hierdie gasheer",
"endpointHost": "Eindpuntgasheer",
"endpointHostPlaceholder": "Dieselfde gasheer, SSH-gasheer of bereikbare gasheer/IP",
"endpointPort": "Eindpuntpoort",
"failedToConnectTunnel": "Kon nie koppel nie",
"failedToDisconnectTunnel": "Kon nie ontkoppel nie",
"maxRetries": "Maksimum herprobeer",
"noTunnelsConfigured": "Geen tonnels gekonfigureer nie.",
"retryIntervalS": "Herprobeerinterval (s)",
"serverTunnelsSection": "Bedienertonnels",
"sourcePort": "Bronpoort",
"tabTunnels": "Tonnels",
"tunnelConnecting": "Tonnel verbind...",
"tunnelDisconnected": "Tonnel ontkoppel",
"tunnelLabel": "Tonnel {{number}}",
"tunnelModeDynamicDesc": "Skep 'n SOCKS5-instaanbediener op 'n plaaslike poort vir dinamiese poortaanstuuring.",
"tunnelModeLocalDesc": "Stuur 'n plaaslike poort aan na 'n poort op die afstandbediener (of 'n gasheer wat daarvandaan bereik kan word).",
"tunnelModeRemoteDesc": "Stuur 'n poort op die afgeleë bediener terug na 'n plaaslike poort op jou masjien.",
"tunnelRequirementsText": "Die SSH-bediener moet GatewayPorts ja, AllowTcpForwarding ja, en PermitRootLogin ja gestel hê in /etc/ssh/sshd_config.",
"tunnelSettings": "Tonnelinstellings",
"tunnelType": "Tonnel Tipe",
"bindHost": "Bind gasheer",
"docsLink": "Bekyk dokumente",
"hostNotFound": "Gasheer nie gevind nie",
"loadingHost": "Laai gasheer..."
},
"nav": {
"portForwarding": "Aanstuur",
"tunnels": "Tonnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (luister plaaslik)",
"localTargetHost": "127.0.0.1 (teiken op hierdie rekenaar)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (luister op eindpunt)",
"remoteTargetHost": "127.0.0.1 (teiken vanaf eindpunt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-luisteraar)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} plaaslike kliënttonnel(s) gekonfigureer op hierdie lessenaar.",
"c2sLoadPreset": "Laai",
"c2sNoPresetSelected": "Geen voorafinstelling gekies nie",
"c2sNoPresets": "Geen voorinstellings gestoor nie",
"c2sPresetDeleted": "Kliënttonnel-voorinstelling verwyder",
"c2sPresetLoadError": "Kon nie kliënttonnelvoorinstellings laai nie",
"c2sPresetLoaded": "Kliënttonnelvoorinstelling plaaslik gelaai",
"c2sPresetName": "Voorafingestelde Naam",
"c2sPresetNamePlaceholder": "Kliëntvooringestelde naam",
"c2sPresetRenamed": "Kliënttonnel-voorinstelling hernoem",
"c2sPresetSaved": "Kliënttonnelvoorinstelling gestoor",
"c2sPresetSyncNote": "Voorinstellings is eksplisiete kiekies; die laai van een vervang hierdie lessenaarkliënt se plaaslike kliënttonnellys.",
"c2sPresetToLoad": "Voorafinstelling om te laai",
"c2sTunnelPresets": "Kliënttonnelvoorinstellings",
"c2sTunnelPresetsUnavailable": "Kliënttunnelvoorinstellings is slegs beskikbaar in die rekenaarkliënt.",
"c2sPresetRenameError": "Kon nie die voorinstelling hernoem nie",
"c2sPresetDeleteError": "Kon nie die voorafinstelling verwyder nie"
},
"tunnels": {
"attempt": "Poging {{current}} van {{max}}",
"autoNameClientDynamic": "SOKKIES {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Plaaslik {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → plaaslik {{localPort}}",
"autoStart": "Outomatiese Begin",
"cancel": "Kanselleer",
"canceling": "Kanselleer tans...",
"clientAutoStartDesc": "Begin wanneer hierdie rekenaarkliënt oopmaak en gekoppel bly.",
"clientManualStartDesc": "Gebruik Begin en Stop vanaf hierdie ry. Termix sal dit nie outomaties oopmaak nie.",
"clientRemoteServerNote": "Afstandsaanstuuring mag AllowTcpForwarding en GatewayPorts op die eindpunt SSH-bediener vereis. Die afstandspoort sluit wanneer hierdie lessenaar ontkoppel.",
"clientTunnelStarted": "Kliënttonnel het begin",
"clientTunnelStopped": "Kliënttonnel gestop",
"clientTunnels": "Kliënttonnels",
"connected": "Verbonde",
"connecting": "Verbind...",
"createFirstTunnelMessage": "Jy het nog geen SSH-tonnels geskep nie. Konfigureer tonnelverbindings in die Gasheerbestuurder om te begin.",
"currentHostPort": "Huidige gasheerpoort",
"destination": "Bestemming",
"destinationAddress": "Bestemmingsadres",
"destinationPort": "Bestemmingshawe",
"disconnected": "Ontkoppel",
"duplicateAutoStartBind": "Slegs een outomatiese begin-kliënttonnel kan {{bind}} gebruik.",
"endpointPort": "Eindpuntpoort",
"endpointSshHost": "Eindpunt SSH-gasheer",
"endpointSshHostPlaceholder": "Kies 'n gekonfigureerde gasheer",
"endpointSshHostRequired": "Kies 'n eindpunt SSH-gasheer vir elke kliënttunnel.",
"error": "Fout",
"forwardDescriptionClientDynamic": "SOCKS op plaaslike poort {{sourcePort}}.",
"forwardDescriptionClientLocal": "Plaaslik {{sourcePort}} → afgeleë {{endpointPort}}.",
"forwardDescriptionClientRemote": "Afstand {{sourcePort}} → plaaslik {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS op huidige gasheer {{sourcePort}}.",
"forwardDescriptionServerLocal": "Huidige gasheer {{sourcePort}} → eindpunt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Eindpunt {{endpointPort}} → huidige gasheer {{sourcePort}}.",
"host": "Gasheer",
"intermediateHost": "Intermediêre gasheer",
"invalidBindIp": "Plaaslike IP moet 'n geldige IPv4-adres wees.",
"invalidEndpointPort": "Eindpuntpoort moet tussen 1 en 65535 wees.",
"invalidLocalPort": "Plaaslike poort moet tussen 1 en 65535 wees.",
"invalidLocalTargetIp": "Die plaaslike teiken-IP moet 'n geldige IPv4-adres wees.",
"invalidLocalTargetPort": "Die plaaslike teikenpoort moet tussen 1 en 65535 wees.",
"invalidRemoteAddress": "Afstandadres word vereis.",
"invalidRemotePort": "Afstandpoort moet tussen 1 en 65535 wees.",
"lastError": "Laaste fout",
"lastStarted": "Laas begin",
"lastTested": "Laas getoets",
"localAddress": "Plaaslike Adres",
"localPort": "Plaaslike Hawe",
"localSaveError": "Kon nie plaaslike kliënttonnels stoor nie",
"localSaved": "Kliënttonnels gestoor",
"manualControlError": "Kon nie tonnelstatus opdateer nie.",
"maxRetries": "Maksimum herprobeer",
"mode": "Modus",
"nextRetryIn": "Volgende herprobeer oor {{seconds}} sekondes",
"noClientTunnels": "Geen kliënttunnels is op hierdie lessenaar gekonfigureer nie.",
"noHostSelected": "Geen gasheer gekies nie",
"noSshTunnels": "Geen SSH-tonnels nie",
"port": "Hawe",
"remoteBindAddress": "Afstandsbindadres",
"remoteHost": "Afstandgasheer",
"remotePort": "Afstandpoort",
"retryInterval": "Herprobeerinterval (sekondes)",
"route": "Roete:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOKKIES via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} op {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Toets",
"tunnelName": "Tonnelnaam",
"tunnelTestFailed": "Tonneltoets het misluk",
"tunnelTestSucceeded": "Tonneltoets het geslaag",
"type": "Tonnel Tipe",
"typeDynamic": "Dinamiese (-D)",
"typeLocal": "Plaaslik (-L)",
"typeRemote": "Afstandbeheer (-R)",
"working": "Werk...",
"activeCount": "{{connected}}/{{total}} aktief",
"listensOn": "Gebonde aan plaaslike gasheer:{{port}}",
"socksProxy": "SOCKS5-instaanbediener",
"waiting": "Wag",
"remoteSessionExpired": "Afstandsinchronisasie-sessie het verval. Koppel weer aan Afstandsinchronisasie en probeer weer.",
"activeTunnels": "Aktiewe Tonnels",
"start": "Begin",
"stop": "Stop",
"authTitle": "Tonnel-verifikasie",
"authRetry": "Die vorige kode is nie aanvaar nie. Voer 'n nuwe kode in.",
"authAnswer": "Verifikasierespons",
"authContinue": "Gaan voort"
},
"permissions": {
"use": {
"title": "Gebruik tonnels",
"description": "Begin, stop en kyk na SSH-tonnels, en stoor kliënttonnelvoorinstellings."
}
},
"settings": {
"host": {
"enable": {
"label": "Aktiveer tonnels",
"description": "Stel SSH-tonnels deur hierdie gasheer op."
}
}
},
"onboarding": {
"feature_tunnels": "Tonnels",
"feature_tunnels_desc": "Stuur poorte deur 'n bediener aan om dienste te bereik wat nie blootgestel is nie."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "إضافة نفق",
"autoStartDesc": "الاتصال بهذا النفق تلقائياً عند تحميل المضيف",
"autoStartLabel": "تشغيل تلقائي",
"connectBtn": "اتصال",
"disconnectBtn": "قطع الاتصال",
"enableTunneling": "تمكين الأنفاق",
"enableTunnelingDesc": "تمكين وظيفة نفق SSH لهذا المضيف",
"endpointHost": "مضيف نقطة النهاية",
"endpointHostPlaceholder": "نفس المضيف، مضيف SSH، أو مضيف/IP يمكن الوصول إليه",
"endpointPort": "منفذ نقطة النهاية",
"failedToConnectTunnel": "فشل الاتصال",
"failedToDisconnectTunnel": "فشل قطع الاتصال",
"maxRetries": "الحد الأقصى لإعادة المحاولات",
"noTunnelsConfigured": "لم يتم تكوين أي أنفاق.",
"retryIntervalS": "فترة إعادة المحاولة (بالثواني)",
"serverTunnelsSection": "أنفاق الخادم",
"sourcePort": "منفذ المصدر",
"tabTunnels": "الأنفاق",
"tunnelConnecting": "جارٍ توصيل النفق...",
"tunnelDisconnected": "تم قطع اتصال النفق",
"tunnelLabel": "نفق {{number}}",
"tunnelModeDynamicDesc": "إنشاء وكيل SOCKS5 على منفذ محلي لتوجيه المنافذ الديناميكي.",
"tunnelModeLocalDesc": "توجيه منفذ محلي إلى منفذ على الخادم البعيد (أو مضيف يمكن الوصول إليه منه).",
"tunnelModeRemoteDesc": "إعادة توجيه منفذ على الخادم البعيد إلى منفذ محلي على جهازك.",
"tunnelRequirementsText": "يجب أن يحتوي خادم SSH على الإعدادات GatewayPorts yes و AllowTcpForwarding yes و PermitRootLogin yes في الملف /etc/ssh/sshd_config.",
"tunnelSettings": "إعدادات النفق",
"tunnelType": "نوع النفق",
"bindHost": "مضيف الربط",
"docsLink": "عرض المستندات",
"hostNotFound": "المضيف غير موجود",
"loadingHost": "جارٍ تحميل المضيف..."
},
"nav": {
"portForwarding": "إعادة التوجيه",
"tunnels": "الأنفاق"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (استماع محلي)",
"localTargetHost": "127.0.0.1 (الهدف على هذا الكمبيوتر)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (الاستماع على نقطة النهاية)",
"remoteTargetHost": "127.0.0.1 (الهدف من نقطة النهاية)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (مستمع SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "تم تكوين {{count}} نفق محلي للعميل على سطح المكتب هذا.",
"c2sLoadPreset": "تحميل",
"c2sNoPresetSelected": "لم يتم تحديد إعداد مُسبَق",
"c2sNoPresets": "لا توجد إعدادات مُسبَقة محفوظة",
"c2sPresetDeleted": "تم حذف الإعداد المُسبَق لنفق العميل",
"c2sPresetLoadError": "فشل في تحميل إعدادات نفق العميل المُسبَقة",
"c2sPresetLoaded": "تم تحميل الإعداد المُسبَق لنفق العميل محلياً",
"c2sPresetName": "اسم الإعداد المُسبَق",
"c2sPresetNamePlaceholder": "اسم الإعداد المُسبَق للعميل",
"c2sPresetRenamed": "تمت إعادة تسمية الإعداد المُسبَق لنفق العميل",
"c2sPresetSaved": "تم حفظ الإعداد المُسبَق لنفق العميل",
"c2sPresetSyncNote": "الإعدادات المُسبَقة هي لقطات واضحة؛ تحميل أحدها يستبدل قائمة الأنفاق المحلية لعميل سطح المكتب هذا.",
"c2sPresetToLoad": "الإعداد المُسبَق للتحميل",
"c2sTunnelPresets": "إعدادات النفق المُسبَقة للعميل",
"c2sTunnelPresetsUnavailable": "إعدادات النفق المُسبَقة للعميل متاحة فقط في تطبيق سطح المكتب.",
"c2sPresetRenameError": "فشل في إعادة تسمية الإعداد المسبق",
"c2sPresetDeleteError": "فشل حذف الإعداد المسبق"
},
"tunnels": {
"attempt": "محاولة {{current}} من {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} عبر {{endpoint}}",
"autoNameClientLocal": "محلي {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → محلي {{localPort}}",
"autoStart": "بدء تلقائي",
"cancel": "إلغاء",
"canceling": "جارٍ الإلغاء...",
"clientAutoStartDesc": "يبدأ عند فتح عميل سطح المكتب هذا ويبقى متصلاً.",
"clientManualStartDesc": "استخدم بدء وإيقاف من هذا الصف. لن يقوم Termix بفتحه تلقائيًا.",
"clientRemoteServerNote": "قد يتطلب التوجيه البعيد AllowTcpForwarding و GatewayPorts على خادم SSH للنهاية. يُغلق المنفذ البعيد عند فصل سطح المكتب هذا.",
"clientTunnelStarted": "تم بدء نفق العميل",
"clientTunnelStopped": "تم إيقاف نفق العميل",
"clientTunnels": "أنفاق العميل",
"connected": "متصل",
"connecting": "جارٍ الاتصال...",
"createFirstTunnelMessage": "لم تُنشئ أي أنفاق SSH بعد. قم بتكوين اتصالات الأنفاق في مدير المضيف للبدء.",
"currentHostPort": "منفذ المضيف الحالي",
"destination": "الوجهة",
"destinationAddress": "عنوان الوجهة",
"destinationPort": "ميناء الوجهة",
"disconnected": "غير متصل",
"duplicateAutoStartBind": "يمكن لنفق عميل تلقائي التشغيل واحد فقط استخدام {{bind}}.",
"endpointPort": "منفذ النهاية",
"endpointSshHost": "مضيف SSH للنهاية",
"endpointSshHostPlaceholder": "اختر مضيفًا مُعيّنًا",
"endpointSshHostRequired": "اختر مضيف SSH للنهاية لكل نفق عميل.",
"error": "خطأ",
"forwardDescriptionClientDynamic": "SOCKS على المنفذ المحلي {{sourcePort}}.",
"forwardDescriptionClientLocal": "من المحلي {{sourcePort}} إلى البعيد {{endpointPort}}.",
"forwardDescriptionClientRemote": "من البعيد {{sourcePort}} إلى المحلي {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS على المضيف الحالي {{sourcePort}}.",
"forwardDescriptionServerLocal": "من المضيف الحالي {{sourcePort}} إلى نقطة النهاية {{endpointPort}}.",
"forwardDescriptionServerRemote": "من نقطة النهاية {{endpointPort}} إلى المضيف الحالي {{sourcePort}}.",
"host": "المضيف",
"intermediateHost": "المضيف الوسيط",
"invalidBindIp": "يجب أن يكون عنوان IP المحلي عنوان IPv4 صالحًا.",
"invalidEndpointPort": "يجب أن يكون منفذ النهاية بين 1 و 65535.",
"invalidLocalPort": "يجب أن يكون المنفذ المحلي بين 1 و 65535.",
"invalidLocalTargetIp": "يجب أن يكون عنوان IP الهدف المحلي عنوان IPv4 صالحًا.",
"invalidLocalTargetPort": "يجب أن يكون منفذ الهدف المحلي بين 1 و 65535.",
"invalidRemoteAddress": "يلزم وجود عنوان بعيد.",
"invalidRemotePort": "يجب أن يكون المنفذ البعيد بين 1 و 65535.",
"lastError": "آخر خطأ",
"lastStarted": "آخر تشغيل",
"lastTested": "آخر اختبار",
"localAddress": "العنوان المحلي",
"localPort": "المنفذ المحلي",
"localSaveError": "فشل حفظ أنفاق العميل المحلية",
"localSaved": "تم حفظ أنفاق العميل",
"manualControlError": "فشل تحديث حالة النفق.",
"maxRetries": "الحد الأقصى لإعادة المحاولات",
"mode": "الوضع",
"nextRetryIn": "إعادة المحاولة التالية في {{seconds}} ثانية",
"noClientTunnels": "لا توجد أنفاق عميل مُعدّة على سطح المكتب هذا.",
"noHostSelected": "لم يتم تحديد مضيف",
"noSshTunnels": "لا توجد أنفاق SSH",
"port": "منفذ",
"remoteBindAddress": "عنوان الربط عن بعد",
"remoteHost": "المضيف البعيد",
"remotePort": "المنفذ البعيد",
"retryInterval": "فاصل إعادة المحاولة (ثوانٍ)",
"route": "المسار:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS عبر {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} عبر {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "اختبار",
"tunnelName": "اسم النفق",
"tunnelTestFailed": "فشل اختبار النفق",
"tunnelTestSucceeded": "نجح اختبار النفق",
"type": "نوع النفق",
"typeDynamic": "ديناميكي (-D)",
"typeLocal": "محلي (-L)",
"typeRemote": "بعيد (-R)",
"working": "جارٍ العمل...",
"activeCount": "{{connected}}/{{total}} نشط",
"listensOn": "مرتبط بالمضيف المحلي:{{port}}",
"socksProxy": "بروكسي SOCKS5",
"waiting": "منتظر",
"remoteSessionExpired": "انتهت صلاحية جلسة المزامنة عن بُعد. أعد الاتصال بالمزامنة عن بُعد وحاول مرة أخرى.",
"activeTunnels": "الأنفاق النشطة",
"start": "تشغيل",
"stop": "إيقاف",
"authTitle": "مصادقة النفق",
"authRetry": "لم يتم قبول الرمز السابق. أدخل رمزاً جديداً.",
"authAnswer": "استجابة المصادقة",
"authContinue": "متابعة"
},
"permissions": {
"use": {
"title": "استخدم الأنفاق",
"description": "ابدأ، أوقف، وراقب أنفاق SSH، واحفظ إعدادات نفق العميل المسبقة."
}
},
"settings": {
"host": {
"enable": {
"label": "تفعيل الأنفاق",
"description": "قم بإعداد أنفاق SSH عبر هذا المضيف."
}
}
},
"onboarding": {
"feature_tunnels": "الأنفاق",
"feature_tunnels_desc": "إعادة توجيه المنافذ عبر خادم للوصول إلى الخدمات غير المكشوفة."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Добавяне на тунел",
"autoStartDesc": "Автоматично свързване на този тунел при зареждане на хоста",
"autoStartLabel": "Автоматично стартиране",
"connectBtn": "Свържете се",
"disconnectBtn": "Изключване",
"enableTunneling": "Активиране на тунелиране",
"enableTunnelingDesc": "Активиране на SSH тунелната функционалност за този хост",
"endpointHost": "Краен хост",
"endpointHostPlaceholder": "Същият хост, SSH хост или достъпен хост/IP адрес",
"endpointPort": "Порт за крайна точка",
"failedToConnectTunnel": "Свързването не бе успешно",
"failedToDisconnectTunnel": "Прекъсването не бе успешно",
"maxRetries": "Максимален брой повторни опити",
"noTunnelsConfigured": "Няма конфигурирани тунели.",
"retryIntervalS": "Интервал на повторен опит (и)",
"serverTunnelsSection": "Сървърни тунели",
"sourcePort": "Източник Порт",
"tabTunnels": "Тунели",
"tunnelConnecting": "Тунел, свързващ...",
"tunnelDisconnected": "Тунелът е прекъснат",
"tunnelLabel": "Тунел {{number}}",
"tunnelModeDynamicDesc": "Създайте SOCKS5 прокси на локален порт за динамично пренасочване на портове.",
"tunnelModeLocalDesc": "Пренасочване на локален порт към порт на отдалечения сървър (или хост, достъпен от него).",
"tunnelModeRemoteDesc": "Пренасочете порт на отдалечения сървър обратно към локален порт на вашата машина.",
"tunnelRequirementsText": "SSH сървърът трябва да има зададени стойности за GatewayPorts (да), AllowTcpForwarding (да) и PermitRootLogin (да) в /etc/ssh/sshd_config.",
"tunnelSettings": "Настройки на тунела",
"tunnelType": "Тип тунел",
"bindHost": "Свързване на хост",
"docsLink": "Преглед на документи",
"hostNotFound": "Хостът не е намерен",
"loadingHost": "Зареждане на хоста..."
},
"nav": {
"portForwarding": "Препращане",
"tunnels": "Тунели"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (слушане локално)",
"localTargetHost": "127.0.0.1 (цел на този компютър)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (слушане на крайната точка)",
"remoteTargetHost": "127.0.0.1 (цел от крайната точка)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS слушател)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} локални клиентски тунели, конфигурирани на този работен плот.",
"c2sLoadPreset": "Зареждане",
"c2sNoPresetSelected": "Няма избрана предварително зададена настройка",
"c2sNoPresets": "Няма запазени предварително зададени настройки",
"c2sPresetDeleted": "Предварително зададена настройка за тунел на клиента е изтрита",
"c2sPresetLoadError": "Неуспешно зареждане на предварително зададени настройки за тунел на клиента",
"c2sPresetLoaded": "Предварително зададена настройка за тунел на клиента, заредена локално",
"c2sPresetName": "Име на предварително зададена настройка",
"c2sPresetNamePlaceholder": "Име на предварително зададена настройка на клиента",
"c2sPresetRenamed": "Предварителното преименуване на клиентския тунел",
"c2sPresetSaved": "Предварително зададена настройка за тунел на клиента е запазена",
"c2sPresetSyncNote": "Предварителните настройки са изрични снимки; зареждането на такава замества списъка с локални клиентски тунели на този настолен клиент.",
"c2sPresetToLoad": "Предварително зададено зареждане",
"c2sTunnelPresets": "Предварителни настройки на клиентския тунел",
"c2sTunnelPresetsUnavailable": "Предварителните настройки на клиентските тунели са налични само в настолния клиент.",
"c2sPresetRenameError": "Преименуването на предварително зададената настройка не бе успешно",
"c2sPresetDeleteError": "Неуспешно изтриване на предварително зададената настройка"
},
"tunnels": {
"attempt": "Опит {{current}} от {{max}}",
"autoNameClientDynamic": "ЧОРАПИ {{localPort}} чрез {{endpoint}}",
"autoNameClientLocal": "Местно {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → местен {{localPort}}",
"autoStart": "Автоматично стартиране",
"cancel": "Отказ",
"canceling": "Анулиране...",
"clientAutoStartDesc": "Стартира, когато този настолен клиент се отвори и остане свързан.",
"clientManualStartDesc": "Използвайте „Старт“ и „Стоп“ от този ред. Termix няма да го отвори автоматично.",
"clientRemoteServerNote": "Отдалеченото пренасочване може да изисква AllowTcpForwarding и GatewayPorts на SSH сървъра на крайната точка. Отдалеченият порт се затваря, когато този работен плот се изключи.",
"clientTunnelStarted": "Тунелът на клиента е стартиран",
"clientTunnelStopped": "Тунелът на клиента е спрян",
"clientTunnels": "Клиентски тунели",
"connected": "Свързан",
"connecting": "Свързване...",
"createFirstTunnelMessage": "Все още не сте създали SSH тунели. Конфигурирайте тунелни връзки в Host Manager, за да започнете.",
"currentHostPort": "Текущ хост порт",
"destination": "Дестинация",
"destinationAddress": "Адрес на местоназначението",
"destinationPort": "Пристанище на местоназначение",
"disconnected": "Изключен",
"duplicateAutoStartBind": "Само един тунел за автоматично стартиране на клиент може да използва {{bind}}.",
"endpointPort": "Порт за крайна точка",
"endpointSshHost": "SSH хост за крайна точка",
"endpointSshHostPlaceholder": "Изберете конфигуриран хост",
"endpointSshHostRequired": "Изберете SSH хост за крайна точка за всеки клиентски тунел.",
"error": "Грешка",
"forwardDescriptionClientDynamic": "SOCKS на локален порт {{sourcePort}}.",
"forwardDescriptionClientLocal": "Локален {{sourcePort}} → отдалечен {{endpointPort}}.",
"forwardDescriptionClientRemote": "Отдалечено {{sourcePort}} → локално {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS на текущия хост {{sourcePort}}.",
"forwardDescriptionServerLocal": "Текущ хост {{sourcePort}} → крайна точка {{endpointPort}}.",
"forwardDescriptionServerRemote": "Крайна точка {{endpointPort}} → текущ хост {{sourcePort}}.",
"host": "Домакин",
"intermediateHost": "Междинен гостоприемник",
"invalidBindIp": "Локалният IP адрес трябва да е валиден IPv4 адрес.",
"invalidEndpointPort": "Портът на крайната точка трябва да е между 1 и 65535.",
"invalidLocalPort": "Локалният порт трябва да е между 1 и 65535.",
"invalidLocalTargetIp": "Локалният целеви IP адрес трябва да е валиден IPv4 адрес.",
"invalidLocalTargetPort": "Локалният целеви порт трябва да е между 1 и 65535.",
"invalidRemoteAddress": "Изисква се отдалечен адрес.",
"invalidRemotePort": "Отдалеченият порт трябва да е между 1 и 65535.",
"lastError": "Последна грешка",
"lastStarted": "Последно стартирано",
"lastTested": "Последно тествано",
"localAddress": "Местен адрес",
"localPort": "Локален порт",
"localSaveError": "Запазването на тунелите на локалните клиенти не бе успешно",
"localSaved": "Клиентските тунели са запазени",
"manualControlError": "Актуализирането на състоянието на тунела не бе успешно.",
"maxRetries": "Максимален брой повторни опити",
"mode": "Режим",
"nextRetryIn": "Следващ опит след {{seconds}} секунди",
"noClientTunnels": "Няма конфигурирани клиентски тунели на този работен плот.",
"noHostSelected": "Няма избран хост",
"noSshTunnels": "Няма SSH тунели",
"port": "Порт",
"remoteBindAddress": "Адрес за отдалечено свързване",
"remoteHost": "Отдалечен хост",
"remotePort": "Отдалечен порт",
"retryInterval": "Интервал на повторен опит (секунди)",
"route": "Маршрут:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → ЧОРАПИ чрез {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} чрез {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} на {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Тест",
"tunnelName": "Име на тунела",
"tunnelTestFailed": "Тестът на тунела не беше успешен",
"tunnelTestSucceeded": "Тестът в тунела е успешен",
"type": "Тип тунел",
"typeDynamic": "Динамичен (-D)",
"typeLocal": "Местен (-L)",
"typeRemote": "Дистанционно (-R)",
"working": "Работа...",
"activeCount": "{{connected}}/{{total}} активен",
"listensOn": "Свързано с localhost:{{port}}",
"socksProxy": "SOCKS5 прокси",
"waiting": "Чакане",
"remoteSessionExpired": "Сесията за отдалечена синхронизация е изтекла. Свържете се отново с отдалечена синхронизация и опитайте отново.",
"activeTunnels": "Активни тунели",
"start": "Старт",
"stop": "Спри",
"authTitle": "Тунелно удостоверяване",
"authRetry": "Предишният код не беше приет. Въведете нов код.",
"authAnswer": "Отговор за удостоверяване",
"authContinue": "Продължи"
},
"permissions": {
"use": {
"title": "Използвайте тунели",
"description": "Стартирайте, спирайте и гледайте SSH тунели и запазвайте предварително зададени настройки на клиентските тунели."
}
},
"settings": {
"host": {
"enable": {
"label": "Активиране на тунели",
"description": "Настройте SSH тунели през този хост."
}
}
},
"onboarding": {
"feature_tunnels": "Тунели",
"feature_tunnels_desc": "Пренасочване на портове през сървър, за да се достигне до услуги, които не са изложени на риск."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Afegeix un túnel",
"autoStartDesc": "Connecta automàticament aquest túnel quan es carregui l'amfitrió",
"autoStartLabel": "Inici automàtic",
"connectBtn": "Connecta",
"disconnectBtn": "Desconnecta",
"enableTunneling": "Activa la tunelització",
"enableTunnelingDesc": "Habilita la funcionalitat del túnel SSH per a aquest amfitrió",
"endpointHost": "Amfitrió de punt final",
"endpointHostPlaceholder": "Mateix host, host SSH o host/IP accessible",
"endpointPort": "Port de punt final",
"failedToConnectTunnel": "No s'ha pogut connectar",
"failedToDisconnectTunnel": "No s'ha pogut desconnectar",
"maxRetries": "Màxim d'intents",
"noTunnelsConfigured": "No hi ha túnels configurats.",
"retryIntervalS": "Interval de reintent (s)",
"serverTunnelsSection": "Túnels de servidor",
"sourcePort": "Port d'origen",
"tabTunnels": "Túnels",
"tunnelConnecting": "Connexió de túnels...",
"tunnelDisconnected": "Túnel desconnectat",
"tunnelLabel": "Túnel {{number}}",
"tunnelModeDynamicDesc": "Crea un proxy SOCKS5 en un port local per al reenviament dinàmic de ports.",
"tunnelModeLocalDesc": "Reenvia un port local a un port del servidor remot (o a un host accessible des d'aquest).",
"tunnelModeRemoteDesc": "Reenvia un port del servidor remot a un port local de la teva màquina.",
"tunnelRequirementsText": "El servidor SSH ha de tenir definits GatewayPorts sí, AllowTcpForwarding sí i PermitRootLogin sí a /etc/ssh/sshd_config.",
"tunnelSettings": "Configuració del túnel",
"tunnelType": "Tipus de túnel",
"bindHost": "Vincula l'amfitrió",
"docsLink": "Veure documents",
"hostNotFound": "No s'ha trobat l'amfitrió",
"loadingHost": "S'està carregant l'amfitrió..."
},
"nav": {
"portForwarding": "Reenviament",
"tunnels": "Túnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (escolta localment)",
"localTargetHost": "127.0.0.1 (objectiu en aquest ordinador)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (escolta al punt final)",
"remoteTargetHost": "127.0.0.1 (objectiu des del punt final)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (escoltador de SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} túnel(s) de client local configurat(s) en aquest escriptori.",
"c2sLoadPreset": "Carrega",
"c2sNoPresetSelected": "No s'ha seleccionat cap predefinició",
"c2sNoPresets": "No s'han desat presets",
"c2sPresetDeleted": "S'ha suprimit la configuració predefinida del túnel del client",
"c2sPresetLoadError": "No s'han pogut carregar els preajustos del túnel del client",
"c2sPresetLoaded": "Predefinició del túnel del client carregada localment",
"c2sPresetName": "Nom predefinit",
"c2sPresetNamePlaceholder": "Nom predefinit del client",
"c2sPresetRenamed": "S'ha canviat el nom de la predefinició del túnel del client",
"c2sPresetSaved": "S'ha desat la configuració predefinida del túnel del client",
"c2sPresetSyncNote": "Els preajustaments són instantànies explícites; carregar-ne una substitueix la llista de túnels de clients locals d'aquest client d'escriptori.",
"c2sPresetToLoad": "Predefinició per carregar",
"c2sTunnelPresets": "Preajustos del túnel del client",
"c2sTunnelPresetsUnavailable": "Els preajustos del túnel del client només estan disponibles al client d'escriptori.",
"c2sPresetRenameError": "No s'ha pogut canviar el nom de la configuració predefinida",
"c2sPresetDeleteError": "No s'ha pogut suprimir la configuració predefinida"
},
"tunnels": {
"attempt": "Intent {{current}} de {{max}}",
"autoNameClientDynamic": "MITJONS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Inici automàtic",
"cancel": "Cancel·la",
"canceling": "Cancel·lant...",
"clientAutoStartDesc": "S'inicia quan s'obre aquest client d'escriptori i roman connectat.",
"clientManualStartDesc": "Feu servir Start i Stop des d'aquesta fila. Termix no l'obrirà automàticament.",
"clientRemoteServerNote": "El reenviament remot pot requerir AllowTcpForwarding i GatewayPorts al servidor SSH del punt final. El port remot es tanca quan aquest escriptori es desconnecta.",
"clientTunnelStarted": "S'ha iniciat el túnel del client",
"clientTunnelStopped": "Túnel del client aturat",
"clientTunnels": "Túnels de client",
"connected": "Connectat",
"connecting": "Connectant...",
"createFirstTunnelMessage": "Encara no has creat cap túnel SSH. Configureu les connexions del túnel al Gestor d'amfitrions per començar.",
"currentHostPort": "Port d'amfitrió actual",
"destination": "Destinació",
"destinationAddress": "Adreça de destinació",
"destinationPort": "Port de destinació",
"disconnected": "Desconnectat",
"duplicateAutoStartBind": "Només un túnel de client d'inici automàtic pot utilitzar {{bind}}.",
"endpointPort": "Port de punt final",
"endpointSshHost": "Amfitrió SSH de punt final",
"endpointSshHostPlaceholder": "Seleccioneu un amfitrió configurat",
"endpointSshHostRequired": "Seleccioneu un host SSH de punt final per a cada túnel de client.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS al port local {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remot {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remot {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS a l'amfitrió actual {{sourcePort}}.",
"forwardDescriptionServerLocal": "Amfitrió actual {{sourcePort}} → punt final {{endpointPort}}.",
"forwardDescriptionServerRemote": "Punt final {{endpointPort}} → amfitrió actual {{sourcePort}}.",
"host": "Amfitrió",
"intermediateHost": "Hoste intermedi",
"invalidBindIp": "La IP local ha de ser una adreça IPv4 vàlida.",
"invalidEndpointPort": "El port del punt final ha d'estar entre 1 i 65535.",
"invalidLocalPort": "El port local ha d'estar entre 1 i 65535.",
"invalidLocalTargetIp": "La IP de destinació local ha de ser una adreça IPv4 vàlida.",
"invalidLocalTargetPort": "El port de destinació local ha d'estar entre 1 i 65535.",
"invalidRemoteAddress": "Cal una adreça remota.",
"invalidRemotePort": "El port remot ha d'estar entre 1 i 65535.",
"lastError": "Últim error",
"lastStarted": "Últim inici",
"lastTested": "Última prova",
"localAddress": "Adreça local",
"localPort": "Port local",
"localSaveError": "No s'han pogut desar els túnels del client local.",
"localSaved": "Túnels de client desats",
"manualControlError": "No s'ha pogut actualitzar l'estat del túnel.",
"maxRetries": "Màxim d'intents",
"mode": "Mode",
"nextRetryIn": "Proper intent en {{seconds}} segons",
"noClientTunnels": "No hi ha túnels de client configurats en aquest escriptori.",
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"noSshTunnels": "Sense túnels SSH",
"port": "Port",
"remoteBindAddress": "Adreça d'enllaç remota",
"remoteHost": "Amfitrió remot",
"remotePort": "Port remot",
"retryInterval": "Interval de reintent (segons)",
"route": "Ruta:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → MITJONS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} a {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Prova",
"tunnelName": "Nom del túnel",
"tunnelTestFailed": "La prova del túnel ha fallat",
"tunnelTestSucceeded": "La prova del túnel ha tingut èxit",
"type": "Tipus de túnel",
"typeDynamic": "Dinàmic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Comandament a distància (-R)",
"working": "Treballant...",
"activeCount": "{{connected}}/{{total}} actiu",
"listensOn": "Enllaçat a localhost:{{port}}",
"socksProxy": "Proxy SOCKS5",
"waiting": "Esperant",
"remoteSessionExpired": "La sessió de sincronització remota ha caducat. Torneu a connectar la sincronització remota i torneu-ho a provar.",
"activeTunnels": "Túnels actius",
"start": "Inici",
"stop": "Atura",
"authTitle": "Autenticació de túnel",
"authRetry": "No s'ha acceptat el codi anterior. Introduïu-ne un de nou.",
"authAnswer": "Resposta d'autenticació",
"authContinue": "Continua"
},
"permissions": {
"use": {
"title": "Utilitza túnels",
"description": "Inicia, atura i supervisa túnels SSH i desa els preajustos del túnel del client."
}
},
"settings": {
"host": {
"enable": {
"label": "Habilita els túnels",
"description": "Configura túnels SSH a través d'aquest host."
}
}
},
"onboarding": {
"feature_tunnels": "Túnels",
"feature_tunnels_desc": "Reenvia ports a través d'un servidor per arribar a serveis que no estan exposats."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Přidat tunel",
"autoStartDesc": "Automaticky připojit tento tunel po načtení hostitele",
"autoStartLabel": "Automatické spuštění",
"connectBtn": "Připojit",
"disconnectBtn": "Odpojit",
"enableTunneling": "Povolit tunelování",
"enableTunnelingDesc": "Povolit funkci SSH tunelu pro tohoto hostitele",
"endpointHost": "Koncový hostitel",
"endpointHostPlaceholder": "Stejný hostitel, hostitel SSH nebo dostupný hostitel/IP adresa",
"endpointPort": "Port koncového bodu",
"failedToConnectTunnel": "Připojení se nezdařilo",
"failedToDisconnectTunnel": "Odpojení se nezdařilo",
"maxRetries": "Maximální počet opakování",
"noTunnelsConfigured": "Nejsou nakonfigurovány žádné tunely.",
"retryIntervalS": "Interval opakování (s)",
"serverTunnelsSection": "Serverové tunely",
"sourcePort": "Zdrojový port",
"tabTunnels": "Tunely",
"tunnelConnecting": "Spojující tunel...",
"tunnelDisconnected": "Tunel odpojen",
"tunnelLabel": "Tunel {{number}}",
"tunnelModeDynamicDesc": "Vytvořte proxy SOCKS5 na lokálním portu pro dynamické přesměrování portů.",
"tunnelModeLocalDesc": "Přesměrování lokálního portu na port na vzdáleném serveru (nebo na hostitele, který je z něj dosažitelný).",
"tunnelModeRemoteDesc": "Přesměrujte port na vzdáleném serveru zpět na lokální port na vašem počítači.",
"tunnelRequirementsText": "SSH server musí mít v souboru /etc/ssh/sshd_config nastaveny hodnoty GatewayPorts – yes, AllowTcpForwarding – yes a PermitRootLogin – yes.",
"tunnelSettings": "Nastavení tunelu",
"tunnelType": "Typ tunelu",
"bindHost": "Vázat hostitele",
"docsLink": "Zobrazit dokumenty",
"hostNotFound": "Hostitel nenalezen",
"loadingHost": "Načítání hostitele..."
},
"nav": {
"portForwarding": "Přeposílání",
"tunnels": "Tunely"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (poslouchat lokálně)",
"localTargetHost": "127.0.0.1 (cíl na tomto počítači)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (naslouchání na koncovém bodě)",
"remoteTargetHost": "127.0.0.1 (cíl z koncového bodu)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (poslouchač SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokálních klientských tunelů nakonfigurovaných na této ploše.",
"c2sLoadPreset": "Zatížení",
"c2sNoPresetSelected": "Není vybrána žádná předvolba",
"c2sNoPresets": "Žádné předvolby nejsou uloženy",
"c2sPresetDeleted": "Předvolba klientského tunelu smazána",
"c2sPresetLoadError": "Nepodařilo se načíst předvolby tunelu klienta",
"c2sPresetLoaded": "Přednastavení klientského tunelu načteno lokálně",
"c2sPresetName": "Název předvolby",
"c2sPresetNamePlaceholder": "Název předvolby klienta",
"c2sPresetRenamed": "Přednastavení klientského tunelu přejmenováno",
"c2sPresetSaved": "Přednastavení klientského tunelu uloženo",
"c2sPresetSyncNote": "Předvolby jsou explicitní snímky; načtením jedné z nich se nahradí seznam tunelů lokálních klientů tohoto desktopového klienta.",
"c2sPresetToLoad": "Předvolba k načtení",
"c2sTunnelPresets": "Přednastavení klientského tunelu",
"c2sTunnelPresetsUnavailable": "Přednastavení tunelů klienta jsou k dispozici pouze v klientské verzi pro stolní počítače.",
"c2sPresetRenameError": "Nepodařilo se přejmenovat předvolbu",
"c2sPresetDeleteError": "Nepodařilo se smazat předvolbu"
},
"tunnels": {
"attempt": "Pokus {{current}} z {{max}}",
"autoNameClientDynamic": "PONOŽKY {{localPort}} přes {{endpoint}}",
"autoNameClientLocal": "Místní {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → místní {{localPort}}",
"autoStart": "Automatický start",
"cancel": "Zrušit",
"canceling": "Rušení...",
"clientAutoStartDesc": "Spustí se při otevření tohoto desktopového klienta a zůstane připojen.",
"clientManualStartDesc": "Použijte Start a Stop z tohoto řádku. Termix jej neotevře automaticky.",
"clientRemoteServerNote": "Vzdálené přesměrování může vyžadovat AllowTcpForwarding a GatewayPorts na SSH serveru koncového bodu. Vzdálený port se zavře, když se tato plocha odpojí.",
"clientTunnelStarted": "Tunel klienta spuštěn",
"clientTunnelStopped": "Tunel klienta zastaven",
"clientTunnels": "Klientské tunely",
"connected": "Připojeno",
"connecting": "Spojovací...",
"createFirstTunnelMessage": "Zatím jste nevytvořili žádné SSH tunely. Chcete-li začít, nakonfigurujte tunelová připojení ve Správci hostitele.",
"currentHostPort": "Aktuální hostitelský port",
"destination": "Cíl",
"destinationAddress": "Cílová adresa",
"destinationPort": "Cílový přístav",
"disconnected": "Odpojeno",
"duplicateAutoStartBind": "Pouze jeden automaticky spouštěný klientský tunel může používat {{bind}}.",
"endpointPort": "Port koncového bodu",
"endpointSshHost": "Koncový SSH hostitel",
"endpointSshHostPlaceholder": "Vyberte nakonfigurovaného hostitele",
"endpointSshHostRequired": "Vyberte koncový bod SSH pro každý klientský tunel.",
"error": "Chyba",
"forwardDescriptionClientDynamic": "SOCKS na lokálním portu {{sourcePort}}.",
"forwardDescriptionClientLocal": "Místní {{sourcePort}} → vzdálený {{endpointPort}}.",
"forwardDescriptionClientRemote": "Vzdálené {{sourcePort}} → místní {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS na aktuálním hostiteli {{sourcePort}}.",
"forwardDescriptionServerLocal": "Aktuální hostitel {{sourcePort}} → koncový bod {{endpointPort}}.",
"forwardDescriptionServerRemote": "Koncový bod {{endpointPort}} → aktuální hostitel {{sourcePort}}.",
"host": "Hostitel",
"intermediateHost": "Mezihostitel",
"invalidBindIp": "Lokální IP adresa musí být platná IPv4 adresa.",
"invalidEndpointPort": "Koncový port musí být v rozsahu od 1 do 65535.",
"invalidLocalPort": "Místní port musí být v rozsahu od 1 do 65535.",
"invalidLocalTargetIp": "Lokální cílová IP adresa musí být platná IPv4 adresa.",
"invalidLocalTargetPort": "Lokální cílový port musí být v rozmezí od 1 do 65535.",
"invalidRemoteAddress": "Je vyžadována vzdálená adresa.",
"invalidRemotePort": "Vzdálený port musí být v rozsahu od 1 do 65535.",
"lastError": "Poslední chyba",
"lastStarted": "Naposledy spuštěno",
"lastTested": "Naposledy testováno",
"localAddress": "Místní adresa",
"localPort": "Místní port",
"localSaveError": "Nepodařilo se uložit tunely lokálních klientů.",
"localSaved": "Tunely klientů uloženy",
"manualControlError": "Nepodařilo se aktualizovat stav tunelu.",
"maxRetries": "Maximální počet opakování",
"mode": "Režim",
"nextRetryIn": "Další pokus za {{seconds}} sekund",
"noClientTunnels": "Na této ploše nejsou nakonfigurovány žádné klientské tunely.",
"noHostSelected": "Nebyl vybrán žádný hostitel",
"noSshTunnels": "Žádné SSH tunely",
"port": "Port",
"remoteBindAddress": "Adresa vzdálené vazby",
"remoteHost": "Vzdálený hostitel",
"remotePort": "Vzdálený port",
"retryInterval": "Interval opakování (sekundy)",
"route": "Trasa:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → PONOŽKY přes {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} přes {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} na {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Název tunelu",
"tunnelTestFailed": "Test tunelu selhal",
"tunnelTestSucceeded": "Test tunelu byl úspěšný",
"type": "Typ tunelu",
"typeDynamic": "Dynamický (-D)",
"typeLocal": "Místní (-L)",
"typeRemote": "Vzdálené (-R)",
"working": "Pracovní...",
"activeCount": "{{connected}}/{{total}} aktivní",
"listensOn": "Vázáno na localhost:{{port}}",
"socksProxy": "Proxy SOCKS5",
"waiting": "Čekání",
"remoteSessionExpired": "Relace vzdálené synchronizace vypršela. Znovu se připojte k aplikaci Vzdálená synchronizace a zkuste to znovu.",
"activeTunnels": "Aktivní tunely",
"start": "Start",
"stop": "Zastávka",
"authTitle": "Autentizace tunelu",
"authRetry": "Předchozí kód nebyl přijat. Zadejte nový kód.",
"authAnswer": "Odpověď na ověřování",
"authContinue": "Pokračovat"
},
"permissions": {
"use": {
"title": "Používejte tunely",
"description": "Spouštění, zastavování a sledování SSH tunelů a ukládání předvoleb klientských tunelů."
}
},
"settings": {
"host": {
"enable": {
"label": "Povolit tunely",
"description": "Nastavte SSH tunely přes tento hostitel."
}
}
},
"onboarding": {
"feature_tunnels": "Tunely",
"feature_tunnels_desc": "Přesměrování portů přes server pro přístup ke službám, které nejsou vystaveny."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Tilføj tunnel",
"autoStartDesc": "Tilslut automatisk denne tunnel, når værten er indlæst",
"autoStartLabel": "Automatisk start",
"connectBtn": "Forbinde",
"disconnectBtn": "Afbryde",
"enableTunneling": "Aktivér tunnelering",
"enableTunnelingDesc": "Aktivér SSH-tunnelfunktionalitet for denne vært",
"endpointHost": "Endpoint-vært",
"endpointHostPlaceholder": "Samme vært, SSH-vært eller tilgængelig vært/IP",
"endpointPort": "Endepunktsport",
"failedToConnectTunnel": "Kunne ikke oprette forbindelse",
"failedToDisconnectTunnel": "Kunne ikke afbryde forbindelsen",
"maxRetries": "Maks. antal genforsøg",
"noTunnelsConfigured": "Ingen tunneler konfigureret.",
"retryIntervalS": "Gentagelsesinterval (s)",
"serverTunnelsSection": "Servertunneler",
"sourcePort": "Kildeport",
"tabTunnels": "Tunneler",
"tunnelConnecting": "Tunnelforbindelse...",
"tunnelDisconnected": "Tunnelen afbrudt",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Opret en SOCKS5-proxy på en lokal port til dynamisk portvideresendelse.",
"tunnelModeLocalDesc": "Videresend en lokal port til en port på den eksterne server (eller en vært, der kan nås fra den).",
"tunnelModeRemoteDesc": "Videresend en port på den eksterne server tilbage til en lokal port på din maskine.",
"tunnelRequirementsText": "SSH-serveren skal have GatewayPorts angivet ja, AllowTcpForwarding ja og PermitRootLogin ja i /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnelindstillinger",
"tunnelType": "Tunneltype",
"bindHost": "Bind vært",
"docsLink": "Se dokumenter",
"hostNotFound": "Værten blev ikke fundet",
"loadingHost": "Indlæser vært..."
},
"nav": {
"portForwarding": "Videresendelse",
"tunnels": "Tunneler"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lyt lokalt)",
"localTargetHost": "127.0.0.1 (mål på denne computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (lyt på slutpunkt)",
"remoteTargetHost": "127.0.0.1 (mål fra slutpunkt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-lytter)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokale klienttunneler konfigureret på dette skrivebord.",
"c2sLoadPreset": "Indlæs",
"c2sNoPresetSelected": "Ingen forudindstilling valgt",
"c2sNoPresets": "Ingen forudindstillinger gemt",
"c2sPresetDeleted": "Forudindstilling for klienttunnel slettet",
"c2sPresetLoadError": "Kunne ikke indlæse klienttunnelforudindstillinger",
"c2sPresetLoaded": "Forudindstilling for klienttunnel indlæses lokalt",
"c2sPresetName": "Forudindstillet navn",
"c2sPresetNamePlaceholder": "Klientens forudindstillingsnavn",
"c2sPresetRenamed": "Omdøbt forudindstilling for klienttunnel",
"c2sPresetSaved": "Forudindstilling for klienttunnel gemt",
"c2sPresetSyncNote": "Forudindstillinger er eksplicitte snapshots; indlæsning af en erstatter denne desktopklients lokale klienttunnelliste.",
"c2sPresetToLoad": "Forudindstilling til indlæsning",
"c2sTunnelPresets": "Klienttunnelforudindstillinger",
"c2sTunnelPresetsUnavailable": "Forudindstillinger for klienttunnel er kun tilgængelige i desktopklienten.",
"c2sPresetRenameError": "Kunne ikke omdøbe forudindstillingen",
"c2sPresetDeleteError": "Kunne ikke slette forudindstillingen"
},
"tunnels": {
"attempt": "Forsøg {{current}} af {{max}}",
"autoNameClientDynamic": "SOKKER {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}",
"autoStart": "Automatisk start",
"cancel": "Ophæve",
"canceling": "Annullerer...",
"clientAutoStartDesc": "Starter, når denne desktopklient åbnes og forbliver forbundet.",
"clientManualStartDesc": "Brug Start og Stop fra denne række. Termix åbner den ikke automatisk.",
"clientRemoteServerNote": "Fjernvideresendelse kan kræve AllowTcpForwarding og GatewayPorts på slutpunktets SSH-server. Fjernporten lukkes, når denne desktop afbryder forbindelsen.",
"clientTunnelStarted": "Klienttunnel startet",
"clientTunnelStopped": "Klienttunnel stoppet",
"clientTunnels": "Klienttunneler",
"connected": "Forbundet",
"connecting": "Forbinder...",
"createFirstTunnelMessage": "Du har endnu ikke oprettet nogen SSH-tunneler. Konfigurer tunnelforbindelser i Host Manager for at komme i gang.",
"currentHostPort": "Nuværende værtsport",
"destination": "Bestemmelsessted",
"destinationAddress": "Destinationsadresse",
"destinationPort": "Destinationshavn",
"disconnected": "Afbrudt",
"duplicateAutoStartBind": "Kun én autostart-klientunnel kan bruge {{bind}}.",
"endpointPort": "Endepunktsport",
"endpointSshHost": "Endpoint SSH-vært",
"endpointSshHostPlaceholder": "Vælg en konfigureret vært",
"endpointSshHostRequired": "Vælg en SSH-vært for slutpunktet for hver klienttunnel.",
"error": "Fejl",
"forwardDescriptionClientDynamic": "SOCKS på lokal port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokal {{sourcePort}} → fjern {{endpointPort}}.",
"forwardDescriptionClientRemote": "Fjern {{sourcePort}} → lokal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS på nuværende vært {{sourcePort}}.",
"forwardDescriptionServerLocal": "Nuværende vært {{sourcePort}} → slutpunkt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Slutpunkt {{endpointPort}} → nuværende vært {{sourcePort}}.",
"host": "Vært",
"intermediateHost": "Mellemliggende vært",
"invalidBindIp": "Lokal IP-adresse skal være en gyldig IPv4-adresse.",
"invalidEndpointPort": "Slutpunktsporten skal være mellem 1 og 65535.",
"invalidLocalPort": "Den lokale port skal være mellem 1 og 65535.",
"invalidLocalTargetIp": "Den lokale mål-IP-adresse skal være en gyldig IPv4-adresse.",
"invalidLocalTargetPort": "Den lokale målport skal være mellem 1 og 65535.",
"invalidRemoteAddress": "Fjernadresse er påkrævet.",
"invalidRemotePort": "Fjernporten skal være mellem 1 og 65535.",
"lastError": "Sidste fejl",
"lastStarted": "Sidst startet",
"lastTested": "Sidst testet",
"localAddress": "Lokal adresse",
"localPort": "Lokal havn",
"localSaveError": "Kunne ikke gemme lokale klienttunneller",
"localSaved": "Klienttunneler gemt",
"manualControlError": "Kunne ikke opdatere tunnelens tilstand.",
"maxRetries": "Maks. antal genforsøg",
"mode": "Mode",
"nextRetryIn": "Næste forsøg om {{seconds}} sekunder",
"noClientTunnels": "Ingen klienttunneller er konfigureret på dette skrivebord.",
"noHostSelected": "Ingen vært valgt",
"noSshTunnels": "Ingen SSH-tunneler",
"port": "Havn",
"remoteBindAddress": "Fjernbindingsadresse",
"remoteHost": "Fjernvært",
"remotePort": "Fjernport",
"retryInterval": "Interval for nye forsøg (sekunder)",
"route": "Rute:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → STRØMPER via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} på {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Prøve",
"tunnelName": "Tunnelnavn",
"tunnelTestFailed": "Tunneltest mislykkedes",
"tunnelTestSucceeded": "Tunneltesten lykkedes",
"type": "Tunneltype",
"typeDynamic": "Dynamisk (-D)",
"typeLocal": "Lokal (-L)",
"typeRemote": "Fjernbetjening (-R)",
"working": "Arbejder...",
"activeCount": "{{connected}}/{{total}} aktiv",
"listensOn": "Bundet til localhost:{{port}}",
"socksProxy": "SOCKS5-proxy",
"waiting": "Venter",
"remoteSessionExpired": "Fjernsynkroniseringssessionen er udløbet. Genopret forbindelse til fjernsynkronisering, og prøv igen.",
"activeTunnels": "Aktive tunneler",
"start": "Starte",
"stop": "Stop",
"authTitle": "Tunnelgodkendelse",
"authRetry": "Den tidligere kode blev ikke accepteret. Indtast en ny kode.",
"authAnswer": "Godkendelsessvar",
"authContinue": "Fortsætte"
},
"permissions": {
"use": {
"title": "Brug tunneler",
"description": "Start, stop og overvåg SSH-tunneler, og gem forudindstillinger for klienttunneler."
}
},
"settings": {
"host": {
"enable": {
"label": "Aktivér tunneler",
"description": "Opsæt SSH-tunneler gennem denne vært."
}
}
},
"onboarding": {
"feature_tunnels": "Tunneler",
"feature_tunnels_desc": "Videresend porte via en server for at nå tjenester, der ikke er eksponerede."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Tunnel hinzufügen",
"autoStartDesc": "Diesen Tunnel automatisch verbinden, wenn der Host geladen wird",
"autoStartLabel": "Autostart",
"connectBtn": "Verbinden",
"disconnectBtn": "Trennen",
"enableTunneling": "Tunnel aktivieren",
"enableTunnelingDesc": "SSH-Tunnel-Funktionalität für diesen Host aktivieren",
"endpointHost": "Endpunkt-Host",
"endpointHostPlaceholder": "Gleicher Host, SSH-Host oder erreichbarer Host/IP",
"endpointPort": "Endpunkt-Port",
"failedToConnectTunnel": "Verbindungsaufbau fehlgeschlagen",
"failedToDisconnectTunnel": "Trennung fehlgeschlagen",
"maxRetries": "Max. Wiederholungen",
"noTunnelsConfigured": "Keine Tunnel konfiguriert.",
"retryIntervalS": "Wiederholungsintervall (s)",
"serverTunnelsSection": "Server-Tunnel",
"sourcePort": "Quellport",
"tabTunnels": "Tunnel",
"tunnelConnecting": "Tunnel wird verbunden...",
"tunnelDisconnected": "Tunnel getrennt",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Erstellt einen SOCKS5-Proxy auf einem lokalen Port für dynamische Portweiterleitung.",
"tunnelModeLocalDesc": "Leitet einen lokalen Port an einen Port auf dem Remote-Server (oder einen von dort erreichbaren Host) weiter.",
"tunnelModeRemoteDesc": "Leitet einen Port auf dem Remote-Server zurück zu einem lokalen Port auf Ihrem Rechner weiter.",
"tunnelRequirementsText": "Der SSH-Server muss in /etc/ssh/sshd_config die Optionen GatewayPorts yes, AllowTcpForwarding yes und PermitRootLogin yes gesetzt haben.",
"tunnelSettings": "Tunnel-Einstellungen",
"tunnelType": "Tunneltyp",
"bindHost": "Bind-Host",
"docsLink": "Dokumentation anzeigen",
"hostNotFound": "Host nicht gefunden",
"loadingHost": "Host wird geladen..."
},
"nav": {
"portForwarding": "Weiterleitung",
"tunnels": "Tunnel"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lokal lauschen)",
"localTargetHost": "127.0.0.1 (Ziel auf diesem Computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (auf Endpunkt lauschen)",
"remoteTargetHost": "127.0.0.1 (Ziel vom Endpunkt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-Listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokale Client-Tunnel auf diesem Desktop konfiguriert.",
"c2sLoadPreset": "Laden",
"c2sNoPresetSelected": "Keine Voreinstellung ausgewählt",
"c2sNoPresets": "Keine Voreinstellungen gespeichert",
"c2sPresetDeleted": "Client-Tunnel-Voreinstellung gelöscht",
"c2sPresetLoadError": "Fehler beim Laden der Client-Tunnel-Voreinstellungen",
"c2sPresetLoaded": "Client-Tunnel-Voreinstellung lokal geladen",
"c2sPresetName": "Voreinstellungsname",
"c2sPresetNamePlaceholder": "Name der Client-Voreinstellung",
"c2sPresetRenamed": "Client-Tunnel-Voreinstellung umbenannt",
"c2sPresetSaved": "Client-Tunnel-Voreinstellung gespeichert",
"c2sPresetSyncNote": "Voreinstellungen sind explizite Snapshots; das Laden einer Voreinstellung ersetzt die lokale Client-Tunnel-Liste dieses Desktop-Clients.",
"c2sPresetToLoad": "Zu ladende Voreinstellung",
"c2sTunnelPresets": "Client-Tunnel-Voreinstellungen",
"c2sTunnelPresetsUnavailable": "Client-Tunnel-Voreinstellungen sind nur im Desktop-Client verfügbar.",
"c2sPresetRenameError": "Die Voreinstellung konnte nicht umbenannt werden.",
"c2sPresetDeleteError": "Die Voreinstellung konnte nicht gelöscht werden."
},
"tunnels": {
"attempt": "Versuch {{current}} von {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}",
"autoStart": "Autostart",
"cancel": "Abbrechen",
"canceling": "Wird abgebrochen...",
"clientAutoStartDesc": "Startet automatisch, wenn dieser Desktop-Client geöffnet wird und die Verbindung aufrechterhalten wird.",
"clientManualStartDesc": "Verwenden Sie Start und Stopp in dieser Zeile. Termix startet den Tunnel nicht automatisch.",
"clientRemoteServerNote": "Remote-Weiterleitung erfordert möglicherweise AllowTcpForwarding und GatewayPorts auf dem Endpunkt-SSH-Server. Der entfernte Port wird geschlossen, wenn dieser Desktop die Verbindung trennt.",
"clientTunnelStarted": "Client-Tunnel gestartet",
"clientTunnelStopped": "Client-Tunnel gestoppt",
"clientTunnels": "Client-Tunnel",
"connected": "Verbunden",
"connecting": "Verbinde...",
"createFirstTunnelMessage": "Sie haben noch keine SSH-Tunnel erstellt. Konfigurieren Sie Tunnelverbindungen im Host-Manager, um zu beginnen.",
"currentHostPort": "Aktueller Host-Port",
"destination": "Ziel",
"destinationAddress": "Zieladresse",
"destinationPort": "Zielhafen",
"disconnected": "Getrennt",
"duplicateAutoStartBind": "Nur ein automatisch startender Client-Tunnel kann {{bind}} verwenden.",
"endpointPort": "Endpunkt-Port",
"endpointSshHost": "Endpunkt-SSH-Host",
"endpointSshHostPlaceholder": "Konfigurierten Host auswählen",
"endpointSshHostRequired": "Wählen Sie einen Endpunkt-SSH-Host für jeden Client-Tunnel aus.",
"error": "Fehler",
"forwardDescriptionClientDynamic": "SOCKS auf lokalem Port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokal {{sourcePort}} → entfernt {{endpointPort}}.",
"forwardDescriptionClientRemote": "Entfernt {{sourcePort}} → lokal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS auf aktuellem Host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Aktueller Host {{sourcePort}} → Endpunkt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpunkt {{endpointPort}} → aktueller Host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Zwischenwirt",
"invalidBindIp": "Lokale IP muss eine gültige IPv4-Adresse sein.",
"invalidEndpointPort": "Endpunkt-Port muss zwischen 1 und 65535 liegen.",
"invalidLocalPort": "Lokaler Port muss zwischen 1 und 65535 liegen.",
"invalidLocalTargetIp": "Lokale Ziel-IP muss eine gültige IPv4-Adresse sein.",
"invalidLocalTargetPort": "Lokaler Zielport muss zwischen 1 und 65535 liegen.",
"invalidRemoteAddress": "Eine Remote-Adresse ist erforderlich.",
"invalidRemotePort": "Remote-Port muss zwischen 1 und 65535 liegen.",
"lastError": "Letzter Fehler",
"lastStarted": "Zuletzt gestartet",
"lastTested": "Zuletzt getestet",
"localAddress": "Lokale Adresse",
"localPort": "Lokaler Port",
"localSaveError": "Fehler beim Speichern der lokalen Client-Tunnel",
"localSaved": "Client-Tunnel gespeichert",
"manualControlError": "Fehler beim Aktualisieren des Tunnelstatus.",
"maxRetries": "Max. Wiederholungen",
"mode": "Modus",
"nextRetryIn": "Nächster Wiederholungsversuch in {{seconds}} Sekunden",
"noClientTunnels": "Keine Client-Tunnel auf diesem Desktop konfiguriert.",
"noHostSelected": "Kein Host ausgewählt",
"noSshTunnels": "Keine SSH-Tunnel",
"port": "Port",
"remoteBindAddress": "Remote-Bind-Adresse",
"remoteHost": "Remote-Host",
"remotePort": "Remote-Port",
"retryInterval": "Wiederholungsintervall (Sekunden)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} auf {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnelname",
"tunnelTestFailed": "Tunneltest fehlgeschlagen",
"tunnelTestSucceeded": "Tunneltest erfolgreich",
"type": "Tunneltyp",
"typeDynamic": "Dynamisch (-D)",
"typeLocal": "Lokal (-L)",
"typeRemote": "Remote (-R)",
"working": "In Bearbeitung...",
"activeCount": "{{connected}}/{{total}} aktiv",
"listensOn": "An localhost gebunden:{{port}}",
"socksProxy": "SOCKS5-Proxy",
"waiting": "Warten",
"remoteSessionExpired": "Die Remote-Synchronisierungssitzung ist abgelaufen. Stellen Sie die Remote-Synchronisierung erneut her und versuchen Sie es noch einmal.",
"activeTunnels": "Aktive Tunnel",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnelauthentifizierung",
"authRetry": "Der vorherige Code wurde nicht akzeptiert. Geben Sie einen neuen Code ein.",
"authAnswer": "Authentifizierungsantwort",
"authContinue": "Weiter"
},
"permissions": {
"use": {
"title": "Nutze Tunnel",
"description": "Starten, stoppen und überwachen Sie SSH-Tunnel und speichern Sie Client-Tunnel-Voreinstellungen."
}
},
"settings": {
"host": {
"enable": {
"label": "Tunnel aktivieren",
"description": "Richten Sie SSH-Tunnel über diesen Host ein."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnel",
"feature_tunnels_desc": "Leiten Sie Ports über einen Server weiter, um Dienste zu erreichen, die nicht öffentlich zugänglich sind."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Προσθήκη σήραγγας",
"autoStartDesc": "Αυτόματη σύνδεση αυτής της σήραγγας όταν φορτωθεί ο κεντρικός υπολογιστής",
"autoStartLabel": "Αυτόματη εκκίνηση",
"connectBtn": "Συνδέω",
"disconnectBtn": "Αποσυνδέω",
"enableTunneling": "Ενεργοποίηση σήραγγας",
"enableTunnelingDesc": "Ενεργοποίηση λειτουργικότητας σήραγγας SSH για αυτόν τον κεντρικό υπολογιστή",
"endpointHost": "Κεντρικός υπολογιστής τελικού σημείου",
"endpointHostPlaceholder": "Ίδιος κεντρικός υπολογιστής, κεντρικός υπολογιστής SSH ή προσβάσιμος κεντρικός υπολογιστής/IP",
"endpointPort": "Θύρα τελικού σημείου",
"failedToConnectTunnel": "Αποτυχία σύνδεσης",
"failedToDisconnectTunnel": "Η αποσύνδεση απέτυχε",
"maxRetries": "Μέγιστος αριθμός επαναλήψεων",
"noTunnelsConfigured": "Δεν έχουν διαμορφωθεί σήραγγες.",
"retryIntervalS": "Διάστημα επανάληψης (s)",
"serverTunnelsSection": "Σήραγγες διακομιστή",
"sourcePort": "Θύρα πηγής",
"tabTunnels": "Σήραγγες",
"tunnelConnecting": "Σύνδεση σήραγγας...",
"tunnelDisconnected": "Η σήραγγα αποσυνδέθηκε",
"tunnelLabel": "Σήραγγα {{number}}",
"tunnelModeDynamicDesc": "Δημιουργήστε έναν διακομιστή μεσολάβησης SOCKS5 σε μια τοπική θύρα για δυναμική προώθηση θυρών.",
"tunnelModeLocalDesc": "Προώθηση μιας τοπικής θύρας σε μια θύρα στον απομακρυσμένο διακομιστή (ή σε έναν κεντρικό υπολογιστή προσβάσιμο από αυτόν).",
"tunnelModeRemoteDesc": "Προωθήστε μια θύρα στον απομακρυσμένο διακομιστή πίσω σε μια τοπική θύρα στο μηχάνημά σας.",
"tunnelRequirementsText": "Ο διακομιστής SSH πρέπει να έχει ορίσει τις τιμές GatewayPorts yes, AllowTcpForwarding yes και PermitRootLogin yes στο /etc/ssh/sshd_config.",
"tunnelSettings": "Ρυθμίσεις σήραγγας",
"tunnelType": "Τύπος σήραγγας",
"bindHost": "Bind Host",
"docsLink": "Προβολή εγγράφων",
"hostNotFound": "Δεν βρέθηκε κεντρικός υπολογιστής",
"loadingHost": "Φόρτωση κεντρικού υπολογιστή..."
},
"nav": {
"portForwarding": "Προώθηση",
"tunnels": "Σήραγγες"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (ακρόαση τοπικά)",
"localTargetHost": "127.0.0.1 (προορισμός σε αυτόν τον υπολογιστή)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (ακρόαση στο τελικό σημείο)",
"remoteTargetHost": "127.0.0.1 (στόχος από το τελικό σημείο)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (ακροατής SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} τοπική(ές) διοχέτευση(εις) πελάτη που έχουν ρυθμιστεί σε αυτήν την επιφάνεια εργασίας.",
"c2sLoadPreset": "Φορτίο",
"c2sNoPresetSelected": "Δεν έχει επιλεγεί προεπιλογή",
"c2sNoPresets": "Δεν έχουν αποθηκευτεί προεπιλογές",
"c2sPresetDeleted": "Η προεπιλογή σήραγγας πελάτη διαγράφηκε",
"c2sPresetLoadError": "Αποτυχία φόρτωσης προεπιλογών σήραγγας πελάτη",
"c2sPresetLoaded": "Η προεπιλογή σήραγγας πελάτη φορτώθηκε τοπικά",
"c2sPresetName": "Όνομα προεπιλογής",
"c2sPresetNamePlaceholder": "Όνομα προεπιλογής πελάτη",
"c2sPresetRenamed": "Η προεπιλογή σήραγγας πελάτη μετονομάστηκε",
"c2sPresetSaved": "Η προεπιλογή σήραγγας πελάτη αποθηκεύτηκε",
"c2sPresetSyncNote": "Οι προεπιλογές είναι σαφή στιγμιότυπα. Η φόρτωση ενός αντικαθιστά τη λίστα τοπικής σήραγγας πελατών αυτού του υπολογιστή-πελάτη επιφάνειας εργασίας.",
"c2sPresetToLoad": "Προεπιλογή για φόρτωση",
"c2sTunnelPresets": "Προεπιλογές σήραγγας πελάτη",
"c2sTunnelPresetsUnavailable": "Οι προεπιλογές σήραγγας προγράμματος-πελάτη είναι διαθέσιμες μόνο στον υπολογιστή-πελάτη για υπολογιστές.",
"c2sPresetRenameError": "Η μετονομασία της προεπιλογής απέτυχε",
"c2sPresetDeleteError": "Η διαγραφή της προεπιλογής απέτυχε"
},
"tunnels": {
"attempt": "Προσπάθεια {{current}} από {{max}}",
"autoNameClientDynamic": "ΚΑΛΤΣΕΣ {{localPort}} μέσω {{endpoint}}",
"autoNameClientLocal": "Τοπικό {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → τοπικό {{localPort}}",
"autoStart": "Αυτόματη έναρξη",
"cancel": "Ματαίωση",
"canceling": "Ακύρωση...",
"clientAutoStartDesc": "Ξεκινά όταν ανοίξει αυτός ο υπολογιστής-πελάτης επιφάνειας εργασίας και παραμένει συνδεδεμένος.",
"clientManualStartDesc": "Χρησιμοποιήστε τις εντολές Έναρξη και Διακοπή από αυτήν τη γραμμή. Το Termix δεν θα την ανοίξει αυτόματα.",
"clientRemoteServerNote": "Η απομακρυσμένη προώθηση ενδέχεται να απαιτεί AllowTcpForwarding και GatewayPorts στον διακομιστή SSH τελικού σημείου. Η απομακρυσμένη θύρα κλείνει όταν αποσυνδεθεί αυτή η επιφάνεια εργασίας.",
"clientTunnelStarted": "Ξεκίνησε η σήραγγα πελατών",
"clientTunnelStopped": "Η σήραγγα πελατών διακόπηκε",
"clientTunnels": "Σήραγγες Πελατών",
"connected": "Συνδεδεμένος",
"connecting": "Συνδετικός...",
"createFirstTunnelMessage": "Δεν έχετε δημιουργήσει ακόμη σήραγγες SSH. Διαμορφώστε τις συνδέσεις σήραγγας στο Host Manager για να ξεκινήσετε.",
"currentHostPort": "Τρέχουσα θύρα κεντρικού υπολογιστή",
"destination": "Προορισμός",
"destinationAddress": "Διεύθυνση προορισμού",
"destinationPort": "Λιμάνι προορισμού",
"disconnected": "Ασύνδετος",
"duplicateAutoStartBind": "Μόνο μία σήραγγα προγράμματος-πελάτη αυτόματης εκκίνησης μπορεί να χρησιμοποιήσει το {{bind}}.",
"endpointPort": "Θύρα τελικού σημείου",
"endpointSshHost": "Κεντρικός υπολογιστής SSH τελικού σημείου",
"endpointSshHostPlaceholder": "Επιλέξτε έναν διαμορφωμένο κεντρικό υπολογιστή",
"endpointSshHostRequired": "Επιλέξτε έναν κεντρικό υπολογιστή SSH τελικού σημείου για κάθε σήραγγα πελάτη.",
"error": "Σφάλμα",
"forwardDescriptionClientDynamic": "SOCKS στην τοπική θύρα {{sourcePort}}.",
"forwardDescriptionClientLocal": "Τοπικό {{sourcePort}} → απομακρυσμένο {{endpointPort}}.",
"forwardDescriptionClientRemote": "Απομακρυσμένο {{sourcePort}} → τοπικό {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS στον τρέχοντα κεντρικό υπολογιστή {{sourcePort}}.",
"forwardDescriptionServerLocal": "Τρέχων κεντρικός υπολογιστής {{sourcePort}} → τελικό σημείο {{endpointPort}}.",
"forwardDescriptionServerRemote": "Τελικό σημείο {{endpointPort}} → τρέχων κεντρικός υπολογιστής {{sourcePort}}.",
"host": "Πλήθος",
"intermediateHost": "Ενδιάμεσος οικοδεσπότης",
"invalidBindIp": "Η τοπική διεύθυνση IP πρέπει να είναι μια έγκυρη διεύθυνση IPv4.",
"invalidEndpointPort": "Η θύρα τελικού σημείου πρέπει να είναι μεταξύ 1 και 65535.",
"invalidLocalPort": "Η τοπική θύρα πρέπει να είναι μεταξύ 1 και 65535.",
"invalidLocalTargetIp": "Η τοπική IP προορισμού πρέπει να είναι μια έγκυρη διεύθυνση IPv4.",
"invalidLocalTargetPort": "Η τοπική θύρα προορισμού πρέπει να είναι μεταξύ 1 και 65535.",
"invalidRemoteAddress": "Απαιτείται απομακρυσμένη διεύθυνση.",
"invalidRemotePort": "Η απομακρυσμένη θύρα πρέπει να είναι μεταξύ 1 και 65535.",
"lastError": "Τελευταίο σφάλμα",
"lastStarted": "Τελευταία έναρξη",
"lastTested": "Τελευταία δοκιμή",
"localAddress": "Τοπική διεύθυνση",
"localPort": "Τοπικό Λιμάνι",
"localSaveError": "Αποτυχία αποθήκευσης τοπικών σηράγγων πελατών",
"localSaved": "Οι σήραγγες πελατών αποθηκεύτηκαν",
"manualControlError": "Αποτυχία ενημέρωσης κατάστασης σήραγγας.",
"maxRetries": "Μέγιστος αριθμός επαναλήψεων",
"mode": "Τρόπος",
"nextRetryIn": "Επόμενη προσπάθεια σε {{seconds}} δευτερόλεπτα",
"noClientTunnels": "Δεν έχουν ρυθμιστεί σήραγγες-πελάτες σε αυτήν την επιφάνεια εργασίας.",
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"noSshTunnels": "Δεν υπάρχουν σήραγγες SSH",
"port": "Λιμάνι",
"remoteBindAddress": "Διεύθυνση απομακρυσμένης σύνδεσης",
"remoteHost": "Απομακρυσμένος κεντρικός υπολογιστής",
"remotePort": "Απομακρυσμένη θύρα",
"retryInterval": "Διάστημα επανάληψης (δευτερόλεπτα)",
"route": "Διαδρομή:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → ΚΑΛΤΣΕΣ μέσω {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} μέσω {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} στις {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Δοκιμή",
"tunnelName": "Όνομα σήραγγας",
"tunnelTestFailed": "Η δοκιμή σήραγγας απέτυχε",
"tunnelTestSucceeded": "Η δοκιμή της σήραγγας ήταν επιτυχής",
"type": "Τύπος σήραγγας",
"typeDynamic": "Δυναμικό (-D)",
"typeLocal": "Τοπικό (-L)",
"typeRemote": "Τηλεχειριστήριο (-R)",
"working": "Εργαζόμενος...",
"activeCount": "{{connected}}/{{total}} ενεργό",
"listensOn": "Δεσμευμένο με localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Αναμονή",
"remoteSessionExpired": "Η περίοδος λειτουργίας Απομακρυσμένου Συγχρονισμού έληξε. Επανασυνδέστε τον Απομακρυσμένο Συγχρονισμό και δοκιμάστε ξανά.",
"activeTunnels": "Ενεργές σήραγγες",
"start": "Αρχή",
"stop": "Στάση",
"authTitle": "Έλεγχος ταυτότητας σήραγγας",
"authRetry": "Ο προηγούμενος κωδικός δεν έγινε δεκτός. Εισαγάγετε έναν νέο κωδικό.",
"authAnswer": "Απόκριση ελέγχου ταυτότητας",
"authContinue": "Συνεχίζω"
},
"permissions": {
"use": {
"title": "Χρήση σηράγγων",
"description": "Έναρξη, διακοπή και παρακολούθηση σηράγγων SSH και αποθήκευση προεπιλογών σηράγγων πελάτη."
}
},
"settings": {
"host": {
"enable": {
"label": "Ενεργοποίηση σηράγγων",
"description": "Ρυθμίστε σήραγγες SSH μέσω αυτού του κεντρικού υπολογιστή."
}
}
},
"onboarding": {
"feature_tunnels": "Σήραγγες",
"feature_tunnels_desc": "Προώθηση θυρών μέσω ενός διακομιστή για πρόσβαση σε υπηρεσίες που δεν είναι εκτεθειμένες."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Añadir túnel",
"autoStartDesc": "Conectar automáticamente este túnel cuando se cargue el host.",
"autoStartLabel": "Inicio automático",
"connectBtn": "Conectar",
"disconnectBtn": "Desconectar",
"enableTunneling": "Habilitar tunelización",
"enableTunnelingDesc": "Habilitar la funcionalidad de túnel SSH para este host",
"endpointHost": "Host de destino",
"endpointHostPlaceholder": "Mismo host, host SSH o host/dirección IP accesible",
"endpointPort": "Puerto de destino",
"failedToConnectTunnel": "Error al conectar",
"failedToDisconnectTunnel": "Error al desconectar",
"maxRetries": "Reintentos máximos",
"noTunnelsConfigured": "No hay túneles configurados.",
"retryIntervalS": "Intervalo de reintento (s)",
"serverTunnelsSection": "Túneles del servidor",
"sourcePort": "Puerto origen",
"tabTunnels": "Túneles",
"tunnelConnecting": "Túnel conectando...",
"tunnelDisconnected": "Túnel desconectado",
"tunnelLabel": "Túnel {{number}}",
"tunnelModeDynamicDesc": "Crear un proxy SOCKS5 en un puerto local para el reenvío dinámico de puertos.",
"tunnelModeLocalDesc": "Reenviar un puerto local a un puerto en el servidor remoto (o a un host accesible desde él).",
"tunnelModeRemoteDesc": "Reenviar un puerto del servidor remoto a un puerto local en tu equipo.",
"tunnelRequirementsText": "El servidor SSH debe tener GatewayPorts yes, AllowTcpForwarding yes y PermitRootLogin yes configurados en /etc/ssh/sshd_config.",
"tunnelSettings": "Configuración de túnel",
"tunnelType": "Tipo de túnel",
"bindHost": "Host de enlace",
"docsLink": "Ver documentación",
"hostNotFound": "Host no encontrado",
"loadingHost": "Cargando host..."
},
"nav": {
"portForwarding": "Reenvío",
"tunnels": "Túneles"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (escuchar localmente)",
"localTargetHost": "127.0.0.1 (destino en este equipo)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (escuchar en el punto final)",
"remoteTargetHost": "127.0.0.1 (destino desde el punto final)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (escucha SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} túnel(es) de cliente local configurado(s) en este escritorio.",
"c2sLoadPreset": "Cargar",
"c2sNoPresetSelected": "Ningún preajuste seleccionado",
"c2sNoPresets": "No hay preajustes guardados",
"c2sPresetDeleted": "Preajuste de túnel de cliente eliminado",
"c2sPresetLoadError": "Error al cargar los preajustes de túneles de cliente",
"c2sPresetLoaded": "Preajuste de túnel de cliente cargado localmente",
"c2sPresetName": "Nombre del preajuste",
"c2sPresetNamePlaceholder": "Nombre del preajuste de cliente",
"c2sPresetRenamed": "Preajuste de túnel de cliente renombrado",
"c2sPresetSaved": "Preajuste de túnel de cliente guardado",
"c2sPresetSyncNote": "Los preajustes son instantáneas explícitas; cargar uno reemplaza la lista de túneles de cliente local de este cliente de escritorio.",
"c2sPresetToLoad": "Preajuste a cargar",
"c2sTunnelPresets": "Preajustes de túneles de cliente",
"c2sTunnelPresetsUnavailable": "Los preajustes de túneles de cliente solo están disponibles en el cliente de escritorio.",
"c2sPresetRenameError": "No se pudo cambiar el nombre del preajuste.",
"c2sPresetDeleteError": "No se pudo eliminar el preajuste."
},
"tunnels": {
"attempt": "Intento {{current}} de {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} a través de {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Inicio automático",
"cancel": "Cancelar",
"canceling": "Cancelando...",
"clientAutoStartDesc": "Se inicia al abrir este cliente de escritorio y permanece conectado.",
"clientManualStartDesc": "Use Iniciar y Detener desde esta fila. Termix no lo iniciará automáticamente.",
"clientRemoteServerNote": "El reenvío remoto puede requerir AllowTcpForwarding y GatewayPorts en el servidor SSH del endpoint. El puerto remoto se cierra cuando este escritorio se desconecta.",
"clientTunnelStarted": "Túnel de cliente iniciado",
"clientTunnelStopped": "Túnel de cliente detenido",
"clientTunnels": "Túneles de cliente",
"connected": "Conectado",
"connecting": "Conectando...",
"createFirstTunnelMessage": "Aún no has creado ningún túnel SSH. Configura las conexiones de túnel en el Administrador de hosts para empezar.",
"currentHostPort": "Puerto del host actual",
"destination": "Destino",
"destinationAddress": "Dirección de destino",
"destinationPort": "Puerto de destino",
"disconnected": "Desconectado",
"duplicateAutoStartBind": "Solo un túnel de cliente de inicio automático puede usar {{bind}}.",
"endpointPort": "Puerto del endpoint",
"endpointSshHost": "Host SSH del endpoint",
"endpointSshHostPlaceholder": "Seleccionar un host configurado",
"endpointSshHostRequired": "Seleccione un host SSH del endpoint para cada túnel de cliente.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS en puerto local {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remoto {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remoto {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS en el host actual {{sourcePort}}.",
"forwardDescriptionServerLocal": "Host actual {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → host actual {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Huésped intermedio",
"invalidBindIp": "La IP local debe ser una dirección IPv4 válida.",
"invalidEndpointPort": "El puerto del endpoint debe estar entre 1 y 65535.",
"invalidLocalPort": "El puerto local debe estar entre 1 y 65535.",
"invalidLocalTargetIp": "La IP de destino local debe ser una dirección IPv4 válida.",
"invalidLocalTargetPort": "El puerto de destino local debe estar entre 1 y 65535.",
"invalidRemoteAddress": "Se requiere una dirección remota.",
"invalidRemotePort": "El puerto remoto debe estar entre 1 y 65535.",
"lastError": "Último error",
"lastStarted": "Último inicio",
"lastTested": "Última prueba",
"localAddress": "Dirección local",
"localPort": "Puerto local",
"localSaveError": "Error al guardar los túneles de cliente locales",
"localSaved": "Túneles de cliente guardados",
"manualControlError": "Error al actualizar el estado del túnel.",
"maxRetries": "Reintentos máx.",
"mode": "Modo",
"nextRetryIn": "Próximo reintento en {{seconds}} segundos",
"noClientTunnels": "No hay túneles de cliente configurados en este escritorio.",
"noHostSelected": "Ningún host seleccionado",
"noSshTunnels": "Sin túneles SSH",
"port": "Puerto",
"remoteBindAddress": "Dirección de enlace remoto",
"remoteHost": "Host remoto",
"remotePort": "Puerto remoto",
"retryInterval": "Intervalo de reintento (segundos)",
"route": "Ruta:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS a través de {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} vía {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} en {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Probar",
"tunnelName": "Nombre del túnel",
"tunnelTestFailed": "Prueba de túnel fallida",
"tunnelTestSucceeded": "Prueba de túnel exitosa",
"type": "Tipo de túnel",
"typeDynamic": "Dinámico (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remoto (-R)",
"working": "Trabajando...",
"activeCount": "{{connected}}/{{total}} activo",
"listensOn": "Vinculado a localhost:{{port}}",
"socksProxy": "proxy SOCKS5",
"waiting": "Espera",
"remoteSessionExpired": "La sesión de sincronización remota ha caducado. Vuelva a conectar la sincronización remota e inténtelo de nuevo.",
"activeTunnels": "Túneles activos",
"start": "Iniciar",
"stop": "Detener",
"authTitle": "Autenticación de túnel",
"authRetry": "El código anterior no fue aceptado. Introduzca un nuevo código.",
"authAnswer": "Respuesta de autenticación",
"authContinue": "Continuar"
},
"permissions": {
"use": {
"title": "Utilizar túneles",
"description": "Inicia, detén y supervisa los túneles SSH, y guarda las configuraciones predefinidas de los túneles de cliente."
}
},
"settings": {
"host": {
"enable": {
"label": "Habilitar túneles",
"description": "Configure túneles SSH a través de este host."
}
}
},
"onboarding": {
"feature_tunnels": "Túneles",
"feature_tunnels_desc": "Reenvía puertos a través de un servidor para acceder a servicios que no están expuestos."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Lisää tunneli",
"autoStartDesc": "Yhdistä tämä tunneli automaattisesti, kun isäntä ladataan",
"autoStartLabel": "Automaattinen käynnistys",
"connectBtn": "Yhdistä",
"disconnectBtn": "Katkaise yhteys",
"enableTunneling": "Ota tunnelointi käyttöön",
"enableTunnelingDesc": "Ota SSH-tunnelitoiminto käyttöön tälle isännälle",
"endpointHost": "Päätepisteen isäntä",
"endpointHostPlaceholder": "Sama isäntä, SSH-isäntä tai tavoitettava isäntä/IP-osoite",
"endpointPort": "Päätepisteen portti",
"failedToConnectTunnel": "Yhteyden muodostaminen epäonnistui",
"failedToDisconnectTunnel": "Yhteyden katkaiseminen epäonnistui",
"maxRetries": "Maks. uudelleenyritykset",
"noTunnelsConfigured": "Ei tunneleita määritettynä.",
"retryIntervalS": "Uudelleenyritysväli (s)",
"serverTunnelsSection": "Palvelintunnelit",
"sourcePort": "Lähdeportti",
"tabTunnels": "Tunnelit",
"tunnelConnecting": "Tunneli, joka yhdistää...",
"tunnelDisconnected": "Tunneli irrotettu",
"tunnelLabel": "Tunneli {{number}}",
"tunnelModeDynamicDesc": "Luo SOCKS5-välityspalvelin paikalliseen porttiin dynaamista portin edelleenlähetystä varten.",
"tunnelModeLocalDesc": "Lähetä paikallinen portti etäpalvelimen (tai siitä tavoitettavan isännän) porttiin.",
"tunnelModeRemoteDesc": "Lähetä etäpalvelimen portti takaisin oman koneesi paikalliseen porttiin.",
"tunnelRequirementsText": "SSH-palvelimella on oltava GatewayPorts-, AllowTcpForwarding- ja PermitRootLogin-arvot asetettuina tiedostossa /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnelin asetukset",
"tunnelType": "Tunnelin tyyppi",
"bindHost": "Sido isäntä",
"docsLink": "Näytä dokumentit",
"hostNotFound": "Isäntää ei löydy",
"loadingHost": "Ladataan isäntää..."
},
"nav": {
"portForwarding": "Edelleenlähetys",
"tunnels": "Tunnelit"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (kuuntele paikallisesti)",
"localTargetHost": "127.0.0.1 (kohde tässä tietokoneessa)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (kuuntele päätepisteessä)",
"remoteTargetHost": "127.0.0.1 (kohde päätepisteestä)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-kuuntelija)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} paikallista asiakastunnelia, jotka on konfiguroitu tälle työpöydälle.",
"c2sLoadPreset": "Ladata",
"c2sNoPresetSelected": "Ei valittua esiasetusta",
"c2sNoPresets": "Ei tallennettuja esiasetuksia",
"c2sPresetDeleted": "Asiakastunnelin esiasetus poistettu",
"c2sPresetLoadError": "Asiakasohjelman tunnelin esiasetusten lataaminen epäonnistui",
"c2sPresetLoaded": "Asiakastunnelin esiasetus ladattu paikallisesti",
"c2sPresetName": "Esiasetetun nimen",
"c2sPresetNamePlaceholder": "Asiakkaan esiasetetun nimen",
"c2sPresetRenamed": "Asiakastunnelin esiasetettu nimi",
"c2sPresetSaved": "Asiakastunnelin esiasetus tallennettu",
"c2sPresetSyncNote": "Esiasetukset ovat eksplisiittisiä tilannevedoksia; yhden lataaminen korvaa tämän työpöytäasiakkaan paikallisen asiakastunneliluettelon.",
"c2sPresetToLoad": "Ladattava esiasetus",
"c2sTunnelPresets": "Asiakastunnelin esiasetukset",
"c2sTunnelPresetsUnavailable": "Asiakasohjelman tunnelin esiasetukset ovat käytettävissä vain työpöytäsovelluksessa.",
"c2sPresetRenameError": "Esiasetuksen uudelleennimeäminen epäonnistui",
"c2sPresetDeleteError": "Esiasetuksen poistaminen epäonnistui"
},
"tunnels": {
"attempt": "Yritys {{current}} / {{max}}",
"autoNameClientDynamic": "SUKAT {{localPort}} kautta {{endpoint}}",
"autoNameClientLocal": "Paikallinen {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → paikallinen {{localPort}}",
"autoStart": "Automaattinen käynnistys",
"cancel": "Peruuttaa",
"canceling": "Peruutetaan...",
"clientAutoStartDesc": "Käynnistyy, kun tämä työpöytäsovellus avautuu ja pysyy yhteydessä.",
"clientManualStartDesc": "Käytä Käynnistä- ja Pysäytä-painikkeita tältä riviltä. Termix ei avaa sitä automaattisesti.",
"clientRemoteServerNote": "Etä edelleenlähetys saattaa vaatia AllowTcpForwarding- ja GatewayPorts-määritykset SSH-päätepalvelimella. Etäportti sulkeutuu, kun tämä työpöytä katkaisee yhteyden.",
"clientTunnelStarted": "Asiakastunneli aloitettu",
"clientTunnelStopped": "Asiakastunneli pysäytetty",
"clientTunnels": "Asiakastunnelit",
"connected": "Yhdistetty",
"connecting": "Yhdistetään...",
"createFirstTunnelMessage": "Et ole vielä luonut SSH-tunneleita. Aloita määrittämällä tunneliyhteydet Host Managerissa.",
"currentHostPort": "Nykyinen isäntäportti",
"destination": "Määränpää",
"destinationAddress": "Kohdeosoite",
"destinationPort": "Määränpääsatama",
"disconnected": "Yhteys katkennut",
"duplicateAutoStartBind": "Vain yksi automaattisesti käynnistyvä asiakastunneli voi käyttää {{bind}} -merkkiä.",
"endpointPort": "Päätepisteen portti",
"endpointSshHost": "Päätepisteen SSH-isäntä",
"endpointSshHostPlaceholder": "Valitse määritetty isäntä",
"endpointSshHostRequired": "Valitse kullekin asiakastunnelille SSH-päätepisteen isäntä.",
"error": "Virhe",
"forwardDescriptionClientDynamic": "SOCKS paikallisessa portissa {{sourcePort}}.",
"forwardDescriptionClientLocal": "Paikallinen {{sourcePort}} → etäinen {{endpointPort}}.",
"forwardDescriptionClientRemote": "Etäinen {{sourcePort}} → paikallinen {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS nykyisellä koneella {{sourcePort}}.",
"forwardDescriptionServerLocal": "Nykyinen isäntä {{sourcePort}} → päätepiste {{endpointPort}}.",
"forwardDescriptionServerRemote": "Päätepiste {{endpointPort}} → nykyinen isäntä {{sourcePort}}.",
"host": "Isäntä",
"intermediateHost": "Väliaikainen isäntä",
"invalidBindIp": "Paikallisen IP-osoitteen on oltava kelvollinen IPv4-osoite.",
"invalidEndpointPort": "Päätepisteen portin on oltava välillä 1–65535.",
"invalidLocalPort": "Paikallisen portin on oltava välillä 1–65535.",
"invalidLocalTargetIp": "Paikallisen kohde-IP-osoitteen on oltava kelvollinen IPv4-osoite.",
"invalidLocalTargetPort": "Paikallisen kohdeportin on oltava välillä 1–65535.",
"invalidRemoteAddress": "Etäosoite vaaditaan.",
"invalidRemotePort": "Etäportin on oltava välillä 1–65535.",
"lastError": "Viimeisin virhe",
"lastStarted": "Viimeksi aloitettu",
"lastTested": "Viimeksi testattu",
"localAddress": "Paikallinen osoite",
"localPort": "Paikallinen satama",
"localSaveError": "Paikallisten asiakastunnelien tallentaminen epäonnistui",
"localSaved": "Asiakastunnelit tallennettu",
"manualControlError": "Tunnelin tilan päivittäminen epäonnistui.",
"maxRetries": "Maks. uudelleenyritykset",
"mode": "Tila",
"nextRetryIn": "Seuraava uudelleenyritys {{seconds}} sekunnin kuluttua",
"noClientTunnels": "Tälle työpöydälle ei ole määritetty asiakastunneleita.",
"noHostSelected": "Ei valittua isäntää",
"noSshTunnels": "Ei SSH-tunneleita",
"port": "Portti",
"remoteBindAddress": "Etäsidontaosoite",
"remoteHost": "Etäisäntä",
"remotePort": "Etäportti",
"retryInterval": "Uudelleenyritysväli (sekuntia)",
"route": "Reitti:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SUKAT {{endpoint}} kautta",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} kautta {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} kohdassa {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Testata",
"tunnelName": "Tunnelin nimi",
"tunnelTestFailed": "Tunnelitesti epäonnistui",
"tunnelTestSucceeded": "Tunnelitesti onnistui",
"type": "Tunnelin tyyppi",
"typeDynamic": "Dynaaminen (-D)",
"typeLocal": "Paikallinen (-L)",
"typeRemote": "Kauko-ohjain (-R)",
"working": "Työskentely...",
"activeCount": "{{connected}}/{{total}} aktiivinen",
"listensOn": "Sidottu paikalliseen isäntään:{{port}}",
"socksProxy": "SOCKS5-välityspalvelin",
"waiting": "Odotus",
"remoteSessionExpired": "Etäsynkronointi-istunto on vanhentunut. Yhdistä etäsynkronointi uudelleen ja yritä uudelleen.",
"activeTunnels": "Aktiiviset tunnelit",
"start": "Aloita",
"stop": "Stop",
"authTitle": "Tunnelin todennus",
"authRetry": "Edellinen koodi ei hyväksytty. Syötä uusi koodi.",
"authAnswer": "Todennusvastaus",
"authContinue": "Jatkaa"
},
"permissions": {
"use": {
"title": "Käytä tunneleita",
"description": "Käynnistä, pysäytä ja tarkkaile SSH-tunneleita ja tallenna asiakastunnelien esiasetukset."
}
},
"settings": {
"host": {
"enable": {
"label": "Ota tunnelit käyttöön",
"description": "Luo SSH-tunneleita tämän isännän kautta."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnelit",
"feature_tunnels_desc": "Lähetä portteja palvelimen kautta tavoittaaksesi palvelut, jotka eivät ole alttiina."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Ajouter un tunnel",
"autoStartDesc": "Connecter automatiquement ce tunnel lorsque l'hôte est chargé",
"autoStartLabel": "Démarrage automatique",
"connectBtn": "Connecter",
"disconnectBtn": "Déconnecter",
"enableTunneling": "Activer le tunneling",
"enableTunnelingDesc": "Activer la fonctionnalité de tunnel SSH pour cet hôte",
"endpointHost": "Hôte de destination",
"endpointHostPlaceholder": "Même hôte, hôte SSH ou hôte/IP accessible",
"endpointPort": "Port de destination",
"failedToConnectTunnel": "Échec de la connexion",
"failedToDisconnectTunnel": "Échec de la déconnexion",
"maxRetries": "Tentatives max",
"noTunnelsConfigured": "Aucun tunnel configuré.",
"retryIntervalS": "Intervalle de tentative (s)",
"serverTunnelsSection": "Tunnels serveur",
"sourcePort": "Port source",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Connexion du tunnel...",
"tunnelDisconnected": "Tunnel déconnecté",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Crée un proxy SOCKS5 sur un port local pour la redirection de port dynamique.",
"tunnelModeLocalDesc": "Redirige un port local vers un port sur le serveur distant (ou un hôte accessible depuis celui-ci).",
"tunnelModeRemoteDesc": "Redirige un port sur le serveur distant vers un port local sur votre machine.",
"tunnelRequirementsText": "Le serveur SSH doit avoir GatewayPorts yes, AllowTcpForwarding yes et PermitRootLogin yes définis dans /etc/ssh/sshd_config.",
"tunnelSettings": "Paramètres du tunnel",
"tunnelType": "Type de tunnel",
"bindHost": "Hôte de liaison",
"docsLink": "Voir la documentation",
"hostNotFound": "Hôte introuvable",
"loadingHost": "Chargement de l'hôte..."
},
"nav": {
"portForwarding": "Expéditeur",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (écouter en local)",
"localTargetHost": "127.0.0.1 (cible sur cet ordinateur)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (écoute sur le point de terminaison)",
"remoteTargetHost": "127.0.0.1 (cible du point de terminaison)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (écouteur SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} tunnel(s) client local configuré(s) sur ce bureau.",
"c2sLoadPreset": "Charger",
"c2sNoPresetSelected": "Aucun préréglage sélectionné",
"c2sNoPresets": "Aucun préréglage enregistré",
"c2sPresetDeleted": "Préréglage de tunnel client supprimé",
"c2sPresetLoadError": "Échec du chargement des préréglages de tunnel client",
"c2sPresetLoaded": "Préréglage de tunnel client chargé localement",
"c2sPresetName": "Nom du préréglage",
"c2sPresetNamePlaceholder": "Nom du préréglage client",
"c2sPresetRenamed": "Préréglage de tunnel client renommé",
"c2sPresetSaved": "Préréglage de tunnel client enregistré",
"c2sPresetSyncNote": "Les préréglages sont des instantanés explicites ; en charger un remplace la liste de tunnels client locaux de ce client de bureau.",
"c2sPresetToLoad": "Préréglage à charger",
"c2sTunnelPresets": "Préréglages de tunnel client",
"c2sTunnelPresetsUnavailable": "Les préréglages de tunnel client sont disponibles uniquement dans le client de bureau.",
"c2sPresetRenameError": "Impossible de renommer le préréglage",
"c2sPresetDeleteError": "Échec de la suppression du préréglage"
},
"tunnels": {
"attempt": "Tentative {{current}} sur {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Démarrage automatique",
"cancel": "Annuler",
"canceling": "Annulation en cours...",
"clientAutoStartDesc": "Démarre à l'ouverture de ce client de bureau et reste connecté.",
"clientManualStartDesc": "Utilisez Démarrer et Arrêter depuis cette ligne. Termix ne l'ouvrira pas automatiquement.",
"clientRemoteServerNote": "La redirection distante peut nécessiter AllowTcpForwarding et GatewayPorts sur le serveur SSH du point de terminaison. Le port distant se ferme lorsque ce bureau se déconnecte.",
"clientTunnelStarted": "Tunnel client démarré",
"clientTunnelStopped": "Tunnel client arrêté",
"clientTunnels": "Tunnels client",
"connected": "Connecté",
"connecting": "Connexion en cours...",
"createFirstTunnelMessage": "Vous n'avez encore créé aucun tunnel SSH. Configurez des connexions de tunnel dans le Gestionnaire d'hôtes pour commencer.",
"currentHostPort": "Port de l'hôte actuel",
"destination": "Destination",
"destinationAddress": "Adresse de destination",
"destinationPort": "Port de destination",
"disconnected": "Déconnecté",
"duplicateAutoStartBind": "Un seul tunnel client à démarrage automatique peut utiliser {{bind}}.",
"endpointPort": "Port du point de terminaison",
"endpointSshHost": "Hôte SSH du point de terminaison",
"endpointSshHostPlaceholder": "Sélectionner un hôte configuré",
"endpointSshHostRequired": "Sélectionnez un hôte SSH de point de terminaison pour chaque tunnel client.",
"error": "Erreur",
"forwardDescriptionClientDynamic": "SOCKS sur le port local {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → distant {{endpointPort}}.",
"forwardDescriptionClientRemote": "Distant {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS sur l’hôte actuel {{sourcePort}}.",
"forwardDescriptionServerLocal": "Hôte actuel {{sourcePort}} → point de terminaison {{endpointPort}}.",
"forwardDescriptionServerRemote": "Point de terminaison {{endpointPort}} → hôte actuel {{sourcePort}}.",
"host": "Hôte",
"intermediateHost": "Hôte intermédiaire",
"invalidBindIp": "L'IP locale doit être une adresse IPv4 valide.",
"invalidEndpointPort": "Le port du point de terminaison doit être compris entre 1 et 65535.",
"invalidLocalPort": "Le port local doit être compris entre 1 et 65535.",
"invalidLocalTargetIp": "L'IP cible locale doit être une adresse IPv4 valide.",
"invalidLocalTargetPort": "Le port cible local doit être compris entre 1 et 65535.",
"invalidRemoteAddress": "Une adresse distante est requise.",
"invalidRemotePort": "Le port distant doit être compris entre 1 et 65535.",
"lastError": "Dernière erreur",
"lastStarted": "Dernier démarrage",
"lastTested": "Dernier test",
"localAddress": "Adresse locale",
"localPort": "Port local",
"localSaveError": "Échec de l'enregistrement des tunnels client locaux",
"localSaved": "Tunnels client enregistrés",
"manualControlError": "Échec de la mise à jour de l'état du tunnel.",
"maxRetries": "Tentatives maximum",
"mode": "Mode",
"nextRetryIn": "Prochaine tentative dans {{seconds}} secondes",
"noClientTunnels": "Aucun tunnel client configuré sur ce bureau.",
"noHostSelected": "Aucun hôte sélectionné",
"noSshTunnels": "Aucun tunnel SSH",
"port": "Port",
"remoteBindAddress": "Adresse de liaison distante",
"remoteHost": "Hôte distant",
"remotePort": "Port distant",
"retryInterval": "Intervalle de réessai (secondes)",
"route": "Route :",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} sur {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Tester",
"tunnelName": "Nom du tunnel",
"tunnelTestFailed": "Échec du test du tunnel",
"tunnelTestSucceeded": "Test du tunnel réussi",
"type": "Type de tunnel",
"typeDynamic": "Dynamique (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Distant (-R)",
"working": "En cours…",
"activeCount": "{{connected}}/{{total}} actif",
"listensOn": "Lié à localhost :{{port}}",
"socksProxy": "Proxy SOCKS5",
"waiting": "En attendant",
"remoteSessionExpired": "La session de synchronisation à distance a expiré. Veuillez vous reconnecter à la synchronisation à distance et réessayer.",
"activeTunnels": "Tunnels actifs",
"start": "Démarrer",
"stop": "Arrêter",
"authTitle": "Authentification du tunnel",
"authRetry": "Le code précédent n'a pas été accepté. Veuillez saisir un nouveau code.",
"authAnswer": "Réponse d'authentification",
"authContinue": "Continuer"
},
"permissions": {
"use": {
"title": "Utilisez les tunnels",
"description": "Démarrer, arrêter et surveiller les tunnels SSH, et enregistrer les préréglages des tunnels clients."
}
},
"settings": {
"host": {
"enable": {
"label": "Activer les tunnels",
"description": "Configurez des tunnels SSH via cet hôte."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Redirigez les ports via un serveur pour accéder aux services non exposés."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "הוסף מנהרה",
"autoStartDesc": "חבר אוטומטית את המנהרה הזו כאשר המארח נטען",
"autoStartLabel": "הפעלה אוטומטית",
"connectBtn": "לְחַבֵּר",
"disconnectBtn": "לְנַתֵק",
"enableTunneling": "הפעלת מנהור",
"enableTunnelingDesc": "הפעל פונקציונליות מנהרת SSH עבור מארח זה",
"endpointHost": "מארח נקודת קצה",
"endpointHostPlaceholder": "אותו מארח, מארח SSH, או מארח/IP נגיש",
"endpointPort": "יציאת נקודת קצה",
"failedToConnectTunnel": "נכשל בחיבור",
"failedToDisconnectTunnel": "נכשל הניתוק",
"maxRetries": "מקסימום ניסיונות חוזרים",
"noTunnelsConfigured": "לא הוגדרו מנהרות.",
"retryIntervalS": "מרווח זמן לניסיון חוזר (ים)",
"serverTunnelsSection": "מנהרות שרתים",
"sourcePort": "יציאת מקור",
"tabTunnels": "מנהרות",
"tunnelConnecting": "מנהרה מתחברת...",
"tunnelDisconnected": "מנהרה מנותקת",
"tunnelLabel": "מנהרה {{number}}",
"tunnelModeDynamicDesc": "צור פרוקסי SOCKS5 בפורט מקומי עבור העברת פורטים דינמית.",
"tunnelModeLocalDesc": "העברת פורט מקומי לפורט בשרת מרוחק (או למארח שניתן להגיע אליו ממנו).",
"tunnelModeRemoteDesc": "העבר פורט בשרת המרוחק חזרה לפורט מקומי במחשב שלך.",
"tunnelRequirementsText": "שרת ה-SSH חייב להגדיר את האפשרויות GatewayPorts כן, AllowTcpForwarding כן, ו-PermitRootLogin כן ב-/etc/ssh/sshd_config.",
"tunnelSettings": "הגדרות מנהרה",
"tunnelType": "סוג המנהרה",
"bindHost": "מארח קשירה",
"docsLink": "הצג מסמכים",
"hostNotFound": "המארח לא נמצא",
"loadingHost": "טוען מארח..."
},
"nav": {
"portForwarding": "העברה",
"tunnels": "מנהרות"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (האזנה מקומית)",
"localTargetHost": "127.0.0.1 (היעד במחשב זה)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (האזנה בנקודת הקצה)",
"remoteTargetHost": "127.0.0.1 (יעד מנקודת הקצה)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (מאזין SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "מנהרה/ות לקוח מקומיות {{count}} מוגדרות בשולחן עבודה זה.",
"c2sLoadPreset": "לִטעוֹן",
"c2sNoPresetSelected": "לא נבחרה הגדרה מוגדרת מראש",
"c2sNoPresets": "לא נשמרו הגדרות קבועות מראש",
"c2sPresetDeleted": "מנהרת הלקוח מוגדרת מראש נמחקה",
"c2sPresetLoadError": "נכשלה טעינת הגדרות קבועות מראש של מנהרת הלקוח",
"c2sPresetLoaded": "מנהרת לקוח מוגדרת מראש נטענת באופן מקומי",
"c2sPresetName": "שם מוגדר מראש",
"c2sPresetNamePlaceholder": "שם קבוע מראש של הלקוח",
"c2sPresetRenamed": "שם של פריסט של מנהרת הלקוח",
"c2sPresetSaved": "מנהרת הלקוח מוגדרת מראש נשמרה",
"c2sPresetSyncNote": "קביעות מוגדרות מראש הן תמונות בזק מפורשות; טעינת אחת מהן מחליפה את רשימת מנהרות הלקוחות המקומיות של לקוח שולחן העבודה הזה.",
"c2sPresetToLoad": "הגדרה מראש לטעינה",
"c2sTunnelPresets": "קביעות מוגדרות מראש של מנהרת לקוח",
"c2sTunnelPresetsUnavailable": "הגדרות קבועות מראש של מנהרת לקוח זמינות רק בלקוח שולחן העבודה.",
"c2sPresetRenameError": "נכשל שינוי שם הפריסט",
"c2sPresetDeleteError": "מחיקת הפריסט נכשלה"
},
"tunnels": {
"attempt": "ניסיון {{current}} של {{max}}",
"autoNameClientDynamic": "גרביים {{localPort}} דרך {{endpoint}}",
"autoNameClientLocal": "מקומי {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → מקומי {{localPort}}",
"autoStart": "הפעלה אוטומטית",
"cancel": "לְבַטֵל",
"canceling": "מבטל...",
"clientAutoStartDesc": "מתחיל כאשר לקוח שולחן עבודה זה נפתח ונשאר מחובר.",
"clientManualStartDesc": "השתמשו בפקודות התחל ועצירה משורה זו. טרמיקס לא יפתח אותה אוטומטית.",
"clientRemoteServerNote": "ייתכן שהעברת נתונים מרחוק תדרוש את AllowTcpForwarding ו-GatewayPorts בשרת ה-SSH של נקודת הקצה. היציאה המרוחקת נסגרת כאשר שולחן עבודה זה מתנתק.",
"clientTunnelStarted": "מנהרת הלקוחות התחילה",
"clientTunnelStopped": "מנהרת הלקוח נעצרה",
"clientTunnels": "מנהרות לקוח",
"connected": "מְחוּבָּר",
"connecting": "מְקַשֵׁר...",
"createFirstTunnelMessage": "עדיין לא יצרת מנהרות SSH. הגדר חיבורי מנהרות במנהל המארח כדי להתחיל.",
"currentHostPort": "יציאת המארח הנוכחית",
"destination": "יַעַד",
"destinationAddress": "כתובת היעד",
"destinationPort": "נמל יעד",
"disconnected": "מְנוּתָק",
"duplicateAutoStartBind": "רק מנהרת לקוח אחת עם הפעלה אוטומטית יכולה להשתמש ב- {{bind}}.",
"endpointPort": "יציאת נקודת קצה",
"endpointSshHost": "מארח SSH של נקודת קצה",
"endpointSshHostPlaceholder": "בחר מארח מוגדר",
"endpointSshHostRequired": "בחר מארח SSH של נקודת קצה עבור כל מנהרת לקוח.",
"error": "שְׁגִיאָה",
"forwardDescriptionClientDynamic": "SOCKS בפורט המקומי {{sourcePort}}.",
"forwardDescriptionClientLocal": "מקומי {{sourcePort}} → מרוחק {{endpointPort}}.",
"forwardDescriptionClientRemote": "מרוחק {{sourcePort}} → מקומי {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS על המארח הנוכחי {{sourcePort}}.",
"forwardDescriptionServerLocal": "מארח נוכחי {{sourcePort}} → נקודת קצה {{endpointPort}}.",
"forwardDescriptionServerRemote": "נקודת קצה {{endpointPort}} → מארח נוכחי {{sourcePort}}.",
"host": "מְאָרֵחַ",
"intermediateHost": "מארח ביניים",
"invalidBindIp": "כתובת IP מקומית חייבת להיות כתובת IPv4 תקפה.",
"invalidEndpointPort": "יציאת נקודת הקצה חייבת להיות בין 1 ל-65535.",
"invalidLocalPort": "היציאה המקומית חייבת להיות בין 1 ל-65535.",
"invalidLocalTargetIp": "כתובת ה-IP המקומית של היעד חייבת להיות כתובת IPv4 חוקית.",
"invalidLocalTargetPort": "יציאת היעד המקומית חייבת להיות בין 1 ל-65535.",
"invalidRemoteAddress": "נדרשת כתובת מרוחקת.",
"invalidRemotePort": "היציאה המרוחקת חייבת להיות בין 1 ל-65535.",
"lastError": "השגיאה האחרונה",
"lastStarted": "התחיל לאחרונה",
"lastTested": "נבדק לאחרונה",
"localAddress": "כתובת מקומית",
"localPort": "נמל מקומי",
"localSaveError": "נכשלה שמירת מנהרות הלקוח המקומיות",
"localSaved": "מנהרות הלקוח נשמרו",
"manualControlError": "עדכון מצב המנהרה נכשל.",
"maxRetries": "מקסימום ניסיונות חוזרים",
"mode": "מצב",
"nextRetryIn": "ניסיון חוזר הבא בעוד {{seconds}} שניות",
"noClientTunnels": "לא הוגדרו מנהרות לקוח בשולחן עבודה זה.",
"noHostSelected": "לא נבחר מארח",
"noSshTunnels": "אין מנהרות SSH",
"port": "נָמָל",
"remoteBindAddress": "כתובת קישור מרחוק",
"remoteHost": "מארח מרוחק",
"remotePort": "יציאה מרוחקת",
"retryInterval": "מרווח זמן לניסיון חוזר (שניות)",
"route": "מַסלוּל:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → גרביים דרך {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} דרך {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} על {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "מִבְחָן",
"tunnelName": "שם המנהרה",
"tunnelTestFailed": "מבחן המנהרה נכשל",
"tunnelTestSucceeded": "מבחן המנהרה הצליח",
"type": "סוג המנהרה",
"typeDynamic": "דינמי (-D)",
"typeLocal": "מקומי (-L)",
"typeRemote": "מרחוק (-R)",
"working": "עובד...",
"activeCount": "{{connected}}/{{total}} פעיל",
"listensOn": "קשור למארח מקומי:{{port}}",
"socksProxy": "פרוקסי SOCKS5",
"waiting": "הַמתָנָה",
"remoteSessionExpired": "פג תוקף סיום הסנכרון מרחוק. התחבר מחדש לסנכרון מרחוק ונסה שוב.",
"activeTunnels": "מנהרות פעילות",
"start": "הַתחָלָה",
"stop": "לְהַפְסִיק",
"authTitle": "אימות מנהרה",
"authRetry": "הקוד הקודם לא התקבל. הזן קוד חדש.",
"authAnswer": "תגובת אימות",
"authContinue": "לְהַמשִׁיך"
},
"permissions": {
"use": {
"title": "השתמשו במנהרות",
"description": "הפעל, עצור וצפה במנהרות SSH, ושמור הגדרות קבועות מראש של מנהרות לקוח."
}
},
"settings": {
"host": {
"enable": {
"label": "הפעלת מנהרות",
"description": "הגדר מנהרות SSH דרך מארח זה."
}
}
},
"onboarding": {
"feature_tunnels": "מנהרות",
"feature_tunnels_desc": "העברת פורטים דרך שרת כדי להגיע לשירותים שאינם חשופים."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "टनल जोड़ें",
"autoStartDesc": "होस्ट लोड होने पर यह टनल स्वचालित रूप से कनेक्ट करें",
"autoStartLabel": "स्वतः आरंभ",
"connectBtn": "कनेक्ट करें",
"disconnectBtn": "डिस्कनेक्ट करें",
"enableTunneling": "टनलिंग सक्षम करें",
"enableTunnelingDesc": "इस होस्ट के लिए SSH टनल कार्यक्षमता सक्षम करें",
"endpointHost": "एंडपॉइंट होस्ट",
"endpointHostPlaceholder": "समान होस्ट, SSH होस्ट, या पहुंच योग्य होस्ट/IP",
"endpointPort": "एंडपॉइंट पोर्ट",
"failedToConnectTunnel": "कनेक्ट करने में विफल",
"failedToDisconnectTunnel": "डिस्कनेक्ट करने में विफल",
"maxRetries": "अधिकतम पुनर्प्रयास",
"noTunnelsConfigured": "कोई टनल कॉन्फ़िगर नहीं की गई।",
"retryIntervalS": "पुनर्प्रयास अंतराल (s)",
"serverTunnelsSection": "सर्वर टनल",
"sourcePort": "स्रोत पोर्ट",
"tabTunnels": "टनलें",
"tunnelConnecting": "टनल कनेक्ट हो रही है...",
"tunnelDisconnected": "टनल डिस्कनेक्ट हो गई",
"tunnelLabel": "टनल {{number}}",
"tunnelModeDynamicDesc": "गतिशील पोर्ट अग्रेषण के लिए स्थानीय पोर्ट पर SOCKS5 प्रॉक्सी बनाएं।",
"tunnelModeLocalDesc": "स्थानीय पोर्ट को दूरस्थ सर्वर (या उससे पहुंच योग्य किसी होस्ट) के पोर्ट पर अग्रेषित करें।",
"tunnelModeRemoteDesc": "दूरस्थ सर्वर पर एक पोर्ट को अपनी मशीन पर स्थानीय पोर्ट पर वापस अग्रेषित करें।",
"tunnelRequirementsText": "SSH सर्वर में /etc/ssh/sshd_config में GatewayPorts yes, AllowTcpForwarding yes, और PermitRootLogin yes सेट होना चाहिए।",
"tunnelSettings": "टनल सेटिंग्स",
"tunnelType": "टनल प्रकार",
"bindHost": "बाइंड होस्ट",
"docsLink": "दस्तावेज़ देखें",
"hostNotFound": "होस्ट नहीं मिला",
"loadingHost": "होस्ट लोड हो रहा है..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "टनल"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (स्थानीय रूप से सुनें)",
"localTargetHost": "127.0.0.1 (इस कंप्यूटर पर लक्ष्य)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS श्रोता)"
},
"profile": {
"c2sCurrentLocalConfig": "इस डेस्कटॉप पर {{count}} स्थानीय क्लाइंट टनल(s) कॉन्फ़िगर की गई हैं।",
"c2sLoadPreset": "लोड करें",
"c2sNoPresetSelected": "कोई प्रीसेट चयनित नहीं",
"c2sNoPresets": "कोई प्रीसेट सहेजा नहीं गया",
"c2sPresetDeleted": "क्लाइंट टनल प्रीसेट हटाया गया",
"c2sPresetLoadError": "क्लाइंट टनल प्रीसेट लोड करने में विफल",
"c2sPresetLoaded": "क्लाइंट टनल प्रीसेट स्थानीय रूप से लोड हुआ",
"c2sPresetName": "प्रीसेट नाम",
"c2sPresetNamePlaceholder": "क्लाइंट प्रीसेट नाम",
"c2sPresetRenamed": "क्लाइंट टनल प्रीसेट का नाम बदला गया",
"c2sPresetSaved": "क्लाइंट टनल प्रीसेट सहेजा गया",
"c2sPresetSyncNote": "प्रीसेट स्पष्ट स्नैपशॉट हैं; एक लोड करने पर यह डेस्कटॉप क्लाइंट की स्थानीय क्लाइंट टनल सूची को बदल देता है।",
"c2sPresetToLoad": "लोड करने के लिए प्रीसेट",
"c2sTunnelPresets": "क्लाइंट टनल प्रीसेट",
"c2sTunnelPresetsUnavailable": "क्लाइंट टनल प्रीसेट केवल डेस्कटॉप क्लाइंट में उपलब्ध हैं।",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "प्रयास {{current}} / {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "स्थानीय {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → स्थानीय {{localPort}}",
"autoStart": "स्वतः आरंभ",
"cancel": "रद्द करें",
"canceling": "रद्द कर रहा है...",
"clientAutoStartDesc": "यह डेस्कटॉप क्लाइंट खुलने पर आरंभ होता है और जुड़ा रहता है।",
"clientManualStartDesc": "इस पंक्ति से आरंभ और रोकें का उपयोग करें। Termix इसे स्वचालित रूप से नहीं खोलेगा।",
"clientRemoteServerNote": "दूरस्थ अग्रेषण के लिए एंडपॉइंट SSH सर्वर पर AllowTcpForwarding और GatewayPorts की आवश्यकता हो सकती है। यह डेस्कटॉप डिस्कनेक्ट होने पर दूरस्थ पोर्ट बंद हो जाता है।",
"clientTunnelStarted": "क्लाइंट टनल आरंभ हो गई",
"clientTunnelStopped": "क्लाइंट टनल रुक गई",
"clientTunnels": "क्लाइंट टनलें",
"connected": "कनेक्टेड",
"connecting": "कनेक्ट हो रहा है...",
"createFirstTunnelMessage": "आपने अभी तक कोई SSH टनल नहीं बनाई है। शुरू करने के लिए होस्ट प्रबंधक में टनल कनेक्शन कॉन्फ़िगर करें।",
"currentHostPort": "वर्तमान होस्ट पोर्ट",
"destination": "डेस्टिनेशन",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "डिस्कनेक्टेड",
"duplicateAutoStartBind": "केवल एक स्वतः-आरंभ क्लाइंट टनल {{bind}} का उपयोग कर सकती है।",
"endpointPort": "एंडपॉइंट पोर्ट",
"endpointSshHost": "एंडपॉइंट SSH होस्ट",
"endpointSshHostPlaceholder": "कॉन्फ़िगर किया गया होस्ट चुनें",
"endpointSshHostRequired": "प्रत्येक क्लाइंट टनल के लिए एक एंडपॉइंट SSH होस्ट चुनें।",
"error": "त्रुटि",
"forwardDescriptionClientDynamic": "स्थानीय पोर्ट {{sourcePort}} पर SOCKS।",
"forwardDescriptionClientLocal": "स्थानीय {{sourcePort}} → दूरस्थ {{endpointPort}}।",
"forwardDescriptionClientRemote": "दूरस्थ {{sourcePort}} → स्थानीय {{endpointPort}}।",
"forwardDescriptionServerDynamic": "वर्तमान होस्ट {{sourcePort}} पर SOCKS।",
"forwardDescriptionServerLocal": "वर्तमान होस्ट {{sourcePort}} → एंडपॉइंट {{endpointPort}}।",
"forwardDescriptionServerRemote": "एंडपॉइंट {{endpointPort}} → वर्तमान होस्ट {{sourcePort}}।",
"host": "होस्ट",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "स्थानीय आईपी एक मान्य IPv4 पता होना चाहिए।",
"invalidEndpointPort": "एंडपॉइंट पोर्ट 1 और 65535 के बीच होना चाहिए।",
"invalidLocalPort": "स्थानीय पोर्ट 1 और 65535 के बीच होना चाहिए।",
"invalidLocalTargetIp": "स्थानीय लक्ष्य आईपी एक मान्य IPv4 पता होना चाहिए।",
"invalidLocalTargetPort": "स्थानीय लक्ष्य पोर्ट 1 और 65535 के बीच होना चाहिए।",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "दूरस्थ पोर्ट 1 और 65535 के बीच होना चाहिए।",
"lastError": "अंतिम त्रुटि",
"lastStarted": "अंतिम बार शुरू हुआ",
"lastTested": "अंतिम बार परीक्षण किया गया",
"localAddress": "Local Address",
"localPort": "स्थानीय पोर्ट",
"localSaveError": "स्थानीय क्लाइंट टनल सहेजने में विफल",
"localSaved": "क्लाइंट टनलें सहेजी गईं",
"manualControlError": "टनल स्थिति अपडेट करने में विफल।",
"maxRetries": "अधिकतम पुनर्प्रयास",
"mode": "मोड",
"nextRetryIn": "अगला पुनः प्रयास {{seconds}} सेकंड में",
"noClientTunnels": "इस डेस्कटॉप पर कोई क्लाइंट टनल कॉन्फ़िगर नहीं है।",
"noHostSelected": "कोई होस्ट चयनित नहीं",
"noSshTunnels": "कोई SSH टनल नहीं",
"port": "पोर्ट",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "दूरस्थ होस्ट",
"remotePort": "दूरस्थ पोर्ट",
"retryInterval": "पुनर्प्रयास अंतराल (सेकंड)",
"route": "रूट:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "परीक्षण करें",
"tunnelName": "टनल का नाम",
"tunnelTestFailed": "टनल परीक्षण विफल",
"tunnelTestSucceeded": "टनल परीक्षण सफल",
"type": "टनल प्रकार",
"typeDynamic": "डायनामिक (-D)",
"typeLocal": "स्थानीय (-L)",
"typeRemote": "दूरस्थ (-R)",
"working": "कार्यरत...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "सक्रिय टनल",
"start": "शुरू करें",
"stop": "बंद करें",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "जारी रखें"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "टनलें",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Alagút hozzáadása",
"autoStartDesc": "Automatikus csatlakozás ehhez az alagúthoz, amikor a gazdagép betöltődik",
"autoStartLabel": "Automatikus indítás",
"connectBtn": "Csatlakozás",
"disconnectBtn": "Leválasztás",
"enableTunneling": "Alagútkezelés engedélyezése",
"enableTunnelingDesc": "SSH alagút funkció engedélyezése ehhez a gazdagéphez",
"endpointHost": "Végponti gazdagép",
"endpointHostPlaceholder": "Ugyanaz a gép, SSH-gép vagy elérhető gép/IP-cím",
"endpointPort": "Végponti port",
"failedToConnectTunnel": "Sikertelen csatlakozás",
"failedToDisconnectTunnel": "Nem sikerült lecsatlakozni",
"maxRetries": "Max. újrapróbálkozások",
"noTunnelsConfigured": "Nincsenek konfigurált alagutak.",
"retryIntervalS": "Újrapróbálkozási intervallum (s)",
"serverTunnelsSection": "Szerver alagutak",
"sourcePort": "Forrásport",
"tabTunnels": "Alagutak",
"tunnelConnecting": "Alagút összeköt...",
"tunnelDisconnected": "Alagút leválasztva",
"tunnelLabel": "Alagút {{number}}",
"tunnelModeDynamicDesc": "Hozz létre egy SOCKS5 proxyt egy helyi porton a dinamikus porttovábbításhoz.",
"tunnelModeLocalDesc": "Továbbítson egy helyi portot a távoli szerveren (vagy egy onnan elérhető gépen) lévő portra.",
"tunnelModeRemoteDesc": "Továbbítsd a távoli szerver egyik portját a saját gépeden lévő helyi portra.",
"tunnelRequirementsText": "Az SSH szervernek a /etc/ssh/sshd_config fájlban be kell állítania a GatewayPorts yes, az AllowTcpForwarding yes és a PermitRootLogin yes értékeket.",
"tunnelSettings": "Alagútbeállítások",
"tunnelType": "Alagút típusa",
"bindHost": "Kötőállomás",
"docsLink": "Dokumentumok megtekintése",
"hostNotFound": "Nem található gazdagép",
"loadingHost": "Gazdagép betöltése..."
},
"nav": {
"portForwarding": "Szállítmányozás",
"tunnels": "Alagutak"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (helyben hallgatható)",
"localTargetHost": "127.0.0.1 (cél ezen a számítógépen)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (figyelés a végponton)",
"remoteTargetHost": "127.0.0.1 (cél a végponttól)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS figyelő)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} helyi kliens alagút(ok) konfigurálva ezen az asztalon.",
"c2sLoadPreset": "Terhelés",
"c2sNoPresetSelected": "Nincs kiválasztva előbeállítás",
"c2sNoPresets": "Nincsenek mentett előbeállítások",
"c2sPresetDeleted": "Kliens alagút preset törölve",
"c2sPresetLoadError": "Nem sikerült betölteni a kliens alagút előbeállításait",
"c2sPresetLoaded": "A kliens alagút presetje helyben betöltött",
"c2sPresetName": "Előbeállítás neve",
"c2sPresetNamePlaceholder": "Ügyfél előre beállított neve",
"c2sPresetRenamed": "Ügyfél alagút preset átnevezve",
"c2sPresetSaved": "Kliens alagút előre beállított értéke mentve",
"c2sPresetSyncNote": "Az előbeállítások explicit pillanatképek; egyik betöltése felülírja az asztali kliens helyi kliens alagútlistáját.",
"c2sPresetToLoad": "Betöltendő előbeállítás",
"c2sTunnelPresets": "Kliens alagút előbeállításai",
"c2sTunnelPresetsUnavailable": "Az ügyfélalagút-előbeállítások csak az asztali kliensben érhetők el.",
"c2sPresetRenameError": "Nem sikerült átnevezni az előbeállítást",
"c2sPresetDeleteError": "Nem sikerült törölni az előbeállítást"
},
"tunnels": {
"attempt": "{{current}} kísérlet a {{max}}-ből",
"autoNameClientDynamic": "ZOKNIK {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokális {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → helyi {{localPort}}",
"autoStart": "Automatikus indítás",
"cancel": "Mégsem",
"canceling": "Lemondás...",
"clientAutoStartDesc": "Akkor indul el, amikor ez az asztali kliens megnyílik és csatlakoztatva marad.",
"clientManualStartDesc": "Használd a Start és Stop gombokat ebből a sorból. A Termix nem nyitja meg automatikusan.",
"clientRemoteServerNote": "A távoli továbbításhoz szükség lehet az AllowTcpForwarding és a GatewayPorts paraméterekre a végponti SSH-kiszolgálón. A távoli port bezárul, amikor ez az asztal lecsatlakozik.",
"clientTunnelStarted": "Ügyfélalagút elindult",
"clientTunnelStopped": "Kliens alagút leállt",
"clientTunnels": "Ügyfél alagutak",
"connected": "Csatlakoztatva",
"connecting": "Kapcsolódás...",
"createFirstTunnelMessage": "Még nem hozott létre SSH alagutakat. A kezdéshez konfigurálja az alagútkapcsolatokat a Host Managerben.",
"currentHostPort": "Jelenlegi host port",
"destination": "Rendeltetési hely",
"destinationAddress": "Célcím",
"destinationPort": "Célkikötő",
"disconnected": "Szétkapcsolt",
"duplicateAutoStartBind": "Csak egy automatikusan induló kliens alagút használhatja a {{bind}} kapcsolót.",
"endpointPort": "Végponti port",
"endpointSshHost": "Végpont SSH-gazdagép",
"endpointSshHostPlaceholder": "Válasszon ki egy konfigurált gazdagépet",
"endpointSshHostRequired": "Válasszon ki egy végponti SSH-hosztot minden kliens alagúthoz.",
"error": "Hiba",
"forwardDescriptionClientDynamic": "SOCKS a helyi {{sourcePort}} porton.",
"forwardDescriptionClientLocal": "Helyi {{sourcePort}} → távoli {{endpointPort}}.",
"forwardDescriptionClientRemote": "Távoli {{sourcePort}} → helyi {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS a jelenlegi gépen {{sourcePort}}.",
"forwardDescriptionServerLocal": "Aktuális hoszt {{sourcePort}} → végpont {{endpointPort}}.",
"forwardDescriptionServerRemote": "Végpont {{endpointPort}} → aktuális gazdagép {{sourcePort}}.",
"host": "Házigazda",
"intermediateHost": "Köztes gazda",
"invalidBindIp": "A helyi IP-címnek érvényes IPv4-címnek kell lennie.",
"invalidEndpointPort": "A végpont portjának 1 és 65535 között kell lennie.",
"invalidLocalPort": "A helyi portnak 1 és 65535 között kell lennie.",
"invalidLocalTargetIp": "A helyi cél IP-címnek érvényes IPv4-címnek kell lennie.",
"invalidLocalTargetPort": "A helyi célportnak 1 és 65535 között kell lennie.",
"invalidRemoteAddress": "Távoli cím megadása kötelező.",
"invalidRemotePort": "A távoli portnak 1 és 65535 között kell lennie.",
"lastError": "Utolsó hiba",
"lastStarted": "Utolsó indítás",
"lastTested": "Utolsó tesztelés",
"localAddress": "Helyi cím",
"localPort": "Helyi kikötő",
"localSaveError": "Nem sikerült menteni a helyi kliens alagutak.",
"localSaved": "Kliens alagutak mentve",
"manualControlError": "Nem sikerült frissíteni az alagút állapotát.",
"maxRetries": "Max. újrapróbálkozások",
"mode": "Mód",
"nextRetryIn": "Következő újrapróbálkozás {{seconds}} másodperc múlva",
"noClientTunnels": "Nincsenek kliens alagutak konfigurálva ezen az asztalon.",
"noHostSelected": "Nincs kiválasztva gazdagép",
"noSshTunnels": "Nincsenek SSH alagutak",
"port": "Kikötő",
"remoteBindAddress": "Távoli kötési cím",
"remoteHost": "Távoli gép",
"remotePort": "Távoli port",
"retryInterval": "Újrapróbálkozási intervallum (másodperc)",
"route": "Útvonal:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → ZOKNIK {{endpoint}}-on keresztül",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} {{endpoint}} útvonalon",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} itt: {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Teszt",
"tunnelName": "Alagút neve",
"tunnelTestFailed": "Alagútteszt sikertelen",
"tunnelTestSucceeded": "Alagútteszt sikeres",
"type": "Alagút típusa",
"typeDynamic": "Dinamikus (-D)",
"typeLocal": "Helyi (-L)",
"typeRemote": "Távoli (-R)",
"working": "Dolgozó...",
"activeCount": "{{connected}}/{{total}} aktív",
"listensOn": "Localhosthoz kötve:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Várakozás",
"remoteSessionExpired": "A távoli szinkronizálási munkamenet lejárt. Csatlakoztassa újra a távoli szinkronizálást, és próbálja újra.",
"activeTunnels": "Aktív alagutak",
"start": "Indul",
"stop": "Stop",
"authTitle": "Alagút-hitelesítés",
"authRetry": "Az előző kódot nem fogadták el. Adjon meg egy új kódot.",
"authAnswer": "Hitelesítési válasz",
"authContinue": "Folytatás"
},
"permissions": {
"use": {
"title": "Használjon alagutakat",
"description": "SSH alagutak indítása, leállítása és figyelése, valamint kliens alagút-előbeállítások mentése."
}
},
"settings": {
"host": {
"enable": {
"label": "Alagutak engedélyezése",
"description": "SSH alagutakat kell beállítani ezen a hoszton keresztül."
}
}
},
"onboarding": {
"feature_tunnels": "Alagutak",
"feature_tunnels_desc": "Portok továbbítása egy szerveren keresztül a nem elérhető szolgáltatások eléréséhez."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Tambahkan Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Jenis Tunnel",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "Anda belum membuat tunnel SSH apa pun. Konfigurasikan koneksi tunnel di Host Manager untuk memulai.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "Tidak ada tunnel SSH",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Nama Tunnel",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Jenis Tunnel",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Aggiungi tunnel",
"autoStartDesc": "Connetti automaticamente questo tunnel all'avvio dell'host",
"autoStartLabel": "Avvio automatico",
"connectBtn": "Connetti",
"disconnectBtn": "Disconnetti",
"enableTunneling": "Abilita tunneling",
"enableTunnelingDesc": "Abilita la funzionalità di tunnel SSH per questo host",
"endpointHost": "Host di destinazione",
"endpointHostPlaceholder": "Stesso host, host SSH o host/IP raggiungibile",
"endpointPort": "Porta di destinazione",
"failedToConnectTunnel": "Connessione fallita",
"failedToDisconnectTunnel": "Disconnessione fallita",
"maxRetries": "Tentativi massimi",
"noTunnelsConfigured": "Nessun tunnel configurato.",
"retryIntervalS": "Intervallo tentativi (s)",
"serverTunnelsSection": "Tunnel server",
"sourcePort": "Porta sorgente",
"tabTunnels": "Tunnel",
"tunnelConnecting": "Connessione tunnel in corso...",
"tunnelDisconnected": "Tunnel disconnesso",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Crea un proxy SOCKS5 su una porta locale per il port forwarding dinamico.",
"tunnelModeLocalDesc": "Inoltra una porta locale a una porta sul server remoto (o a un host raggiungibile da esso).",
"tunnelModeRemoteDesc": "Inoltra una porta sul server remoto verso una porta locale sulla tua macchina.",
"tunnelRequirementsText": "Il server SSH deve avere GatewayPorts yes, AllowTcpForwarding yes e PermitRootLogin yes impostati in /etc/ssh/sshd_config.",
"tunnelSettings": "Impostazioni tunnel",
"tunnelType": "Tipo di tunnel",
"bindHost": "Host di bind",
"docsLink": "Visualizza documentazione",
"hostNotFound": "Host non trovato",
"loadingHost": "Caricamento host..."
},
"nav": {
"portForwarding": "Inoltro",
"tunnels": "Tunnel"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (ascolta localmente)",
"localTargetHost": "127.0.0.1 (destinazione su questo computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (ascolta sull'endpoint)",
"remoteTargetHost": "127.0.0.1 (destinazione dall'endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (listener SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} tunnel client locale configurato/i su questo desktop.",
"c2sLoadPreset": "Carica",
"c2sNoPresetSelected": "Nessuna preimpostazione selezionata",
"c2sNoPresets": "Nessuna preimpostazione salvata",
"c2sPresetDeleted": "Preimpostazione tunnel client eliminata",
"c2sPresetLoadError": "Caricamento delle preimpostazioni tunnel client non riuscito",
"c2sPresetLoaded": "Preimpostazione tunnel client caricata localmente",
"c2sPresetName": "Nome preimpostazione",
"c2sPresetNamePlaceholder": "Nome preimpostazione client",
"c2sPresetRenamed": "Preimpostazione tunnel client rinominata",
"c2sPresetSaved": "Preimpostazione tunnel client salvata",
"c2sPresetSyncNote": "Le preimpostazioni sono snapshot espliciti; caricandone una si sostituisce l'elenco dei tunnel client locali di questo desktop.",
"c2sPresetToLoad": "Preimpostazione da caricare",
"c2sTunnelPresets": "Preimpostazioni tunnel client",
"c2sTunnelPresetsUnavailable": "Le preimpostazioni tunnel client sono disponibili solo nel client desktop.",
"c2sPresetRenameError": "Impossibile rinominare il preset",
"c2sPresetDeleteError": "Impossibile eliminare il preset"
},
"tunnels": {
"attempt": "Tentativo {{current}} di {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Locale {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → locale {{localPort}}",
"autoStart": "Avvio automatico",
"cancel": "Annulla",
"canceling": "Annullamento in corso...",
"clientAutoStartDesc": "Si avvia all'apertura del client desktop e rimane connesso.",
"clientManualStartDesc": "Usa Avvia e Arresta da questa riga. Termix non lo avvierà automaticamente.",
"clientRemoteServerNote": "L'inoltro remoto potrebbe richiedere AllowTcpForwarding e GatewayPorts sul server SSH endpoint. La porta remota viene chiusa quando questo desktop si disconnette.",
"clientTunnelStarted": "Tunnel client avviato",
"clientTunnelStopped": "Tunnel client arrestato",
"clientTunnels": "Tunnel client",
"connected": "Connesso",
"connecting": "Connessione in corso...",
"createFirstTunnelMessage": "Non hai ancora creato nessun tunnel SSH. Configura le connessioni tunnel nel Gestore host per iniziare.",
"currentHostPort": "Porta host corrente",
"destination": "Destinazione",
"destinationAddress": "Indirizzo di destinazione",
"destinationPort": "Porto di destinazione",
"disconnected": "Disconnesso",
"duplicateAutoStartBind": "Solo un tunnel client con avvio automatico può utilizzare {{bind}}.",
"endpointPort": "Porta endpoint",
"endpointSshHost": "Host SSH endpoint",
"endpointSshHostPlaceholder": "Seleziona un host configurato",
"endpointSshHostRequired": "Seleziona un host SSH endpoint per ogni tunnel client.",
"error": "Errore",
"forwardDescriptionClientDynamic": "SOCKS su porta locale {{sourcePort}}.",
"forwardDescriptionClientLocal": "Locale {{sourcePort}} → remoto {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remoto {{sourcePort}} → locale {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS su host attuale {{sourcePort}}.",
"forwardDescriptionServerLocal": "Host attuale {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → host attuale {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Host intermedio",
"invalidBindIp": "L'IP locale deve essere un indirizzo IPv4 valido.",
"invalidEndpointPort": "La porta endpoint deve essere compresa tra 1 e 65535.",
"invalidLocalPort": "La porta locale deve essere compresa tra 1 e 65535.",
"invalidLocalTargetIp": "L'IP di destinazione locale deve essere un indirizzo IPv4 valido.",
"invalidLocalTargetPort": "La porta di destinazione locale deve essere compresa tra 1 e 65535.",
"invalidRemoteAddress": "È necessario un indirizzo remoto.",
"invalidRemotePort": "La porta remota deve essere compresa tra 1 e 65535.",
"lastError": "Ultimo errore",
"lastStarted": "Ultimo avvio",
"lastTested": "Ultimo test",
"localAddress": "Indirizzo locale",
"localPort": "Porta locale",
"localSaveError": "Salvataggio dei tunnel client locali non riuscito",
"localSaved": "Tunnel client salvati",
"manualControlError": "Aggiornamento dello stato del tunnel non riuscito.",
"maxRetries": "Tentativi massimi",
"mode": "Modalità",
"nextRetryIn": "Prossimo tentativo tra {{seconds}} secondi",
"noClientTunnels": "Nessun tunnel client configurato su questo desktop.",
"noHostSelected": "Nessun host selezionato",
"noSshTunnels": "Nessun tunnel SSH",
"port": "Porta",
"remoteBindAddress": "Indirizzo di associazione remota",
"remoteHost": "Host remoto",
"remotePort": "Porta remota",
"retryInterval": "Intervallo tra tentativi (secondi)",
"route": "Percorso:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} su {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Verifica",
"tunnelName": "Nome tunnel",
"tunnelTestFailed": "Test del tunnel fallito",
"tunnelTestSucceeded": "Test del tunnel riuscito",
"type": "Tipo di tunnel",
"typeDynamic": "Dinamico (-D)",
"typeLocal": "Locale (-L)",
"typeRemote": "Remoto (-R)",
"working": "In corso...",
"activeCount": "{{connected}}/{{total}} attivo",
"listensOn": "Collegato a localhost:{{port}}",
"socksProxy": "proxy SOCKS5",
"waiting": "In attesa",
"remoteSessionExpired": "La sessione di sincronizzazione remota è scaduta. Riconnettiti alla sincronizzazione remota e riprova.",
"activeTunnels": "Tunnel attivi",
"start": "Avvia",
"stop": "Ferma",
"authTitle": "Autenticazione del tunnel",
"authRetry": "Il codice precedente non è stato accettato. Inserisci un nuovo codice.",
"authAnswer": "Risposta di autenticazione",
"authContinue": "Continua"
},
"permissions": {
"use": {
"title": "Utilizzare i tunnel",
"description": "Avvia, arresta e monitora i tunnel SSH e salva le impostazioni predefinite dei tunnel client."
}
},
"settings": {
"host": {
"enable": {
"label": "Abilita i tunnel",
"description": "Configura i tunnel SSH attraverso questo host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnel",
"feature_tunnels_desc": "Inoltra le porte attraverso un server per raggiungere servizi non esposti."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "トンネルを追加",
"autoStartDesc": "ホスト読み込み時にこのトンネルを自動接続します。",
"autoStartLabel": "自動起動",
"connectBtn": "接続",
"disconnectBtn": "切断",
"enableTunneling": "トンネリングを有効にする",
"enableTunnelingDesc": "このホストのSSHトンネル機能を有効にします",
"endpointHost": "エンドポイントホスト",
"endpointHostPlaceholder": "同じホスト、SSHホスト、または到達可能なホスト/IP",
"endpointPort": "エンドポイントポート",
"failedToConnectTunnel": "接続に失敗しました",
"failedToDisconnectTunnel": "切断に失敗しました",
"maxRetries": "最大再試行回数",
"noTunnelsConfigured": "トンネルが設定されていません",
"retryIntervalS": "再試行間隔(秒)",
"serverTunnelsSection": "サーバートンネル",
"sourcePort": "ソースポート",
"tabTunnels": "トンネル",
"tunnelConnecting": "トンネル接続中...",
"tunnelDisconnected": "トンネル切断",
"tunnelLabel": "トンネル {{number}}",
"tunnelModeDynamicDesc": "動的ポート転送用にローカルポートにSOCKS5プロキシを作成します。",
"tunnelModeLocalDesc": "ローカルポートをリモートサーバー(またはそこから到達可能なホスト)のポートに転送します。",
"tunnelModeRemoteDesc": "リモートサーバーのポートをローカルマシンのポートに転送します。",
"tunnelRequirementsText": "SSHサーバーの/etc/ssh/sshd_configに、GatewayPorts yes、AllowTcpForwarding yes、PermitRootLogin yesが設定されている必要があります。",
"tunnelSettings": "トンネル設定",
"tunnelType": "トンネルの種類",
"bindHost": "バインドホスト",
"docsLink": "ドキュメントを表示",
"hostNotFound": "ホストが見つかりません",
"loadingHost": "ホストを読み込み中..."
},
"nav": {
"portForwarding": "転送",
"tunnels": "トンネル"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1(ローカルでリッスン)",
"localTargetHost": "127.0.0.1(このコンピューターのターゲット)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1(エンドポイントでリッスン)",
"remoteTargetHost": "127.0.0.1(エンドポイントからのターゲット)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKSリスナー)"
},
"profile": {
"c2sCurrentLocalConfig": "このデスクトップに{{count}}個のローカルクライアントトンネルが設定されています。",
"c2sLoadPreset": "読み込み",
"c2sNoPresetSelected": "プリセットが選択されていません",
"c2sNoPresets": "保存されたプリセットはありません",
"c2sPresetDeleted": "クライアントトンネルプリセットを削除しました",
"c2sPresetLoadError": "クライアントトンネルプリセットの読み込みに失敗しました",
"c2sPresetLoaded": "クライアントトンネルプリセットをローカルに読み込みました",
"c2sPresetName": "プリセット名",
"c2sPresetNamePlaceholder": "クライアントプリセット名",
"c2sPresetRenamed": "クライアントトンネルプリセットの名前を変更しました",
"c2sPresetSaved": "クライアントトンネルプリセットを保存しました",
"c2sPresetSyncNote": "プリセットは明示的なスナップショットです。読み込むと、このデスクトップクライアントのローカルクライアントトンネルリストが置き換えられます。",
"c2sPresetToLoad": "読み込むプリセット",
"c2sTunnelPresets": "クライアントトンネルプリセット",
"c2sTunnelPresetsUnavailable": "クライアントトンネルプリセットはデスクトップクライアントでのみ利用可能です。",
"c2sPresetRenameError": "プリセットの名前変更に失敗しました",
"c2sPresetDeleteError": "プリセットの削除に失敗しました"
},
"tunnels": {
"attempt": "試行 {{current}} / {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "ローカル {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → ローカル {{localPort}}",
"autoStart": "自動起動",
"cancel": "キャンセル",
"canceling": "キャンセル中...",
"clientAutoStartDesc": "デスクトップクライアントが起動すると自動的に接続し、接続を維持します。",
"clientManualStartDesc": "この行の開始/停止を使用します。Termixは自動的に起動しません。",
"clientRemoteServerNote": "リモート転送にはエンドポイントSSHサーバーでAllowTcpForwardingとGatewayPortsが必要な場合があります。このデスクトップが切断されるとリモートポートは閉じます。",
"clientTunnelStarted": "クライアントトンネルが起動しました",
"clientTunnelStopped": "クライアントトンネルが停止しました",
"clientTunnels": "クライアントトンネル",
"connected": "接続済み",
"connecting": "接続中...",
"createFirstTunnelMessage": "まだSSHトンネルが作成されていません。開始するにはホストマネージャーでトンネル接続を設定してください。",
"currentHostPort": "現在のホストポート",
"destination": "宛先",
"destinationAddress": "宛先住所",
"destinationPort": "目的地港",
"disconnected": "切断済み",
"duplicateAutoStartBind": "自動起動クライアントトンネルで同じ{{bind}}を使用できるのは1つだけです。",
"endpointPort": "エンドポイントポート",
"endpointSshHost": "エンドポイントSSHホスト",
"endpointSshHostPlaceholder": "設定済みのホストを選択",
"endpointSshHostRequired": "各クライアントトンネルにエンドポイントSSHホストを選択してください。",
"error": "エラー",
"forwardDescriptionClientDynamic": "ローカルポート {{sourcePort}} 上のSOCKS。",
"forwardDescriptionClientLocal": "ローカル {{sourcePort}} → リモート {{endpointPort}}。",
"forwardDescriptionClientRemote": "リモート {{sourcePort}} → ローカル {{endpointPort}}。",
"forwardDescriptionServerDynamic": "現在のホスト上のSOCKS {{sourcePort}}。",
"forwardDescriptionServerLocal": "現在のホスト {{sourcePort}} → エンドポイント {{endpointPort}}。",
"forwardDescriptionServerRemote": "エンドポイント {{endpointPort}} → 現在のホスト {{sourcePort}}。",
"host": "ホスト",
"intermediateHost": "中間宿主",
"invalidBindIp": "ローカルIPは有効なIPv4アドレスでなければなりません。",
"invalidEndpointPort": "エンドポイントポートは1~65535の間でなければなりません。",
"invalidLocalPort": "ローカルポートは1~65535の間でなければなりません。",
"invalidLocalTargetIp": "ローカルターゲットIPは有効なIPv4アドレスでなければなりません。",
"invalidLocalTargetPort": "ローカルターゲットポートは1~65535の間でなければなりません。",
"invalidRemoteAddress": "リモートアドレスが必要です。",
"invalidRemotePort": "リモートポートは1~65535の間でなければなりません。",
"lastError": "最終エラー",
"lastStarted": "最終起動日時",
"lastTested": "最終テスト日時",
"localAddress": "現地住所",
"localPort": "ローカルポート",
"localSaveError": "クライアントトンネルの保存に失敗しました",
"localSaved": "クライアントトンネルを保存しました",
"manualControlError": "トンネル状態の更新に失敗しました。",
"maxRetries": "最大再試行回数",
"mode": "モード",
"nextRetryIn": "{{seconds}} 秒後に再試行",
"noClientTunnels": "このデスクトップにはクライアントトンネルが設定されていません。",
"noHostSelected": "ホストが選択されていません",
"noSshTunnels": "SSHトンネルがありません",
"port": "ポート",
"remoteBindAddress": "リモートバインドアドレス",
"remoteHost": "リモートホスト",
"remotePort": "リモートポート",
"retryInterval": "再試行間隔 (秒)",
"route": "ルート:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} 経由 {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} を {{endpoint}} 上で → {{localHost}}:{{localPort}}",
"test": "テスト",
"tunnelName": "トンネル名",
"tunnelTestFailed": "トンネルテストが失敗しました",
"tunnelTestSucceeded": "トンネルテストが成功しました",
"type": "トンネルタイプ",
"typeDynamic": "ダイナミック (-D)",
"typeLocal": "ローカル (-L)",
"typeRemote": "リモート (-R)",
"working": "処理中...",
"activeCount": "{{connected}}/{{total}} アクティブ",
"listensOn": "localhostにバインドされています:{{port}}",
"socksProxy": "SOCKS5プロキシ",
"waiting": "待っている",
"remoteSessionExpired": "リモート同期セッションの有効期限が切れました。リモート同期に再接続して、もう一度お試しください。",
"activeTunnels": "アクティブトンネル",
"start": "開始",
"stop": "停止",
"authTitle": "トンネル認証",
"authRetry": "以前のコードは受け付けられませんでした。新しいコードを入力してください。",
"authAnswer": "認証応答",
"authContinue": "続行"
},
"permissions": {
"use": {
"title": "トンネルを利用する",
"description": "SSHトンネルの開始、停止、監視、およびクライアントトンネルのプリセットの保存が可能です。"
}
},
"settings": {
"host": {
"enable": {
"label": "トンネルを有効にする",
"description": "このホスト経由でSSHトンネルを設定してください。"
}
}
},
"onboarding": {
"feature_tunnels": "トンネル",
"feature_tunnels_desc": "公開されていないサービスにアクセスできるように、サーバー経由でポートを転送します。"
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "터널 추가",
"autoStartDesc": "호스트가 로드될 때 이 터널을 자동으로 연결합니다.",
"autoStartLabel": "자동 시작",
"connectBtn": "연결",
"disconnectBtn": "연결 해제",
"enableTunneling": "터널링 활성화",
"enableTunnelingDesc": "이 호스트에 대한 SSH 터널 기능을 활성화합니다",
"endpointHost": "엔드포인트 호스트",
"endpointHostPlaceholder": "동일 호스트, SSH 호스트 또는 접근 가능한 호스트/IP",
"endpointPort": "엔드포인트 포트",
"failedToConnectTunnel": "연결 실패",
"failedToDisconnectTunnel": "연결 해제 실패",
"maxRetries": "최대 재시도",
"noTunnelsConfigured": "구성된 터널이 없습니다",
"retryIntervalS": "재시도 간격 (초)",
"serverTunnelsSection": "서버 터널",
"sourcePort": "소스 포트",
"tabTunnels": "터널",
"tunnelConnecting": "터널 연결 중...",
"tunnelDisconnected": "터널 연결 끊김",
"tunnelLabel": "터널 {{number}}",
"tunnelModeDynamicDesc": "동적 포트 포워딩을 위해 로컬 포트에 SOCKS5 프록시를 생성합니다.",
"tunnelModeLocalDesc": "로컬 포트를 원격 서버(또는 해당 서버에서 접근 가능한 호스트)의 포트로 포워딩합니다.",
"tunnelModeRemoteDesc": "원격 서버의 포트를 내 컴퓨터의 로컬 포트로 역방향 포워딩합니다.",
"tunnelRequirementsText": "SSH 서버의 /etc/ssh/sshd_config에 GatewayPorts yes, AllowTcpForwarding yes, PermitRootLogin yes가 설정되어 있어야 합니다.",
"tunnelSettings": "터널 설정",
"tunnelType": "터널 유형",
"bindHost": "바인드 호스트",
"docsLink": "문서 보기",
"hostNotFound": "호스트를 찾을 수 없습니다",
"loadingHost": "호스트 로드 중..."
},
"nav": {
"portForwarding": "전송",
"tunnels": "터널"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (로컬에서 수신 대기)",
"localTargetHost": "127.0.0.1 (이 컴퓨터의 대상)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (엔드포인트에서 수신 대기)",
"remoteTargetHost": "127.0.0.1 (엔드포인트에서 대상 주소)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS 리스너)"
},
"profile": {
"c2sCurrentLocalConfig": "이 데스크톱에 구성된 로컬 클라이언트 터널 {{count}}개.",
"c2sLoadPreset": "불러오기",
"c2sNoPresetSelected": "프리셋이 선택되지 않음",
"c2sNoPresets": "저장된 프리셋이 없음",
"c2sPresetDeleted": "클라이언트 터널 프리셋이 삭제되었습니다.",
"c2sPresetLoadError": "클라이언트 터널 프리셋을 불러오지 못했습니다.",
"c2sPresetLoaded": "클라이언트 터널 프리셋이 로컬에 불러와졌습니다.",
"c2sPresetName": "프리셋 이름",
"c2sPresetNamePlaceholder": "클라이언트 프리셋 이름",
"c2sPresetRenamed": "클라이언트 터널 프리셋의 이름이 변경되었습니다.",
"c2sPresetSaved": "클라이언트 터널 프리셋이 저장되었습니다.",
"c2sPresetSyncNote": "프리셋은 명시적 스냅샷입니다. 프리셋을 불러오면 이 데스크톱 클라이언트의 로컬 클라이언트 터널 목록이 대체됩니다.",
"c2sPresetToLoad": "불러올 프리셋",
"c2sTunnelPresets": "클라이언트 터널 프리셋",
"c2sTunnelPresetsUnavailable": "클라이언트 터널 프리셋은 데스크톱 클라이언트에서만 사용할 수 있습니다.",
"c2sPresetRenameError": "사전 설정 이름을 변경하는 데 실패했습니다.",
"c2sPresetDeleteError": "사전 설정을 삭제하는 데 실패했습니다."
},
"tunnels": {
"attempt": "시도 {{current}}/{{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} ({{endpoint}} 경유)",
"autoNameClientLocal": "로컬 {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → 로컬 {{localPort}}",
"autoStart": "자동 시작",
"cancel": "취소",
"canceling": "취소 중...",
"clientAutoStartDesc": "이 데스크톱 클라이언트가 열리면 자동 시작되고 연결을 유지합니다.",
"clientManualStartDesc": "이 행에서 시작 및 중지를 사용하세요. Termix가 자동으로 열지 않습니다.",
"clientRemoteServerNote": "원격 포워딩은 엔드포인트 SSH 서버에서 AllowTcpForwarding 및 GatewayPorts 설정이 필요할 수 있습니다. 이 데스크톱 연결이 끊어지면 원격 포트가 닫힙니다.",
"clientTunnelStarted": "클라이언트 터널 시작됨",
"clientTunnelStopped": "클라이언트 터널 중지됨",
"clientTunnels": "클라이언트 터널",
"connected": "연결됨",
"connecting": "연결 중...",
"createFirstTunnelMessage": "아직 SSH 터널을 생성하지 않았습니다. 시작하려면 호스트 관리자에서 터널 연결을 구성하세요.",
"currentHostPort": "현재 호스트 포트",
"destination": "목적지",
"destinationAddress": "목적지 주소",
"destinationPort": "목적지 항구",
"disconnected": "연결 끊김",
"duplicateAutoStartBind": "자동 시작 클라이언트 터널 중 하나만 {{bind}}를 사용할 수 있습니다.",
"endpointPort": "엔드포인트 포트",
"endpointSshHost": "엔드포인트 SSH 호스트",
"endpointSshHostPlaceholder": "설정된 호스트 선택",
"endpointSshHostRequired": "각 클라이언트 터널에 엔드포인트 SSH 호스트를 선택하세요.",
"error": "오류",
"forwardDescriptionClientDynamic": "로컬 포트 {{sourcePort}}에서 SOCKS.",
"forwardDescriptionClientLocal": "로컬 {{sourcePort}} → 원격 {{endpointPort}}.",
"forwardDescriptionClientRemote": "원격 {{sourcePort}} → 로컬 {{endpointPort}}.",
"forwardDescriptionServerDynamic": "현재 호스트 {{sourcePort}}에서 SOCKS.",
"forwardDescriptionServerLocal": "현재 호스트 {{sourcePort}} → 엔드포인트 {{endpointPort}}.",
"forwardDescriptionServerRemote": "엔드포인트 {{endpointPort}} → 현재 호스트 {{sourcePort}}.",
"host": "호스트",
"intermediateHost": "중간 호스트",
"invalidBindIp": "로컬 IP는 유효한 IPv4 주소여야 합니다.",
"invalidEndpointPort": "엔드포인트 포트는 1에서 65535 사이여야 합니다.",
"invalidLocalPort": "로컬 포트는 1에서 65535 사이여야 합니다.",
"invalidLocalTargetIp": "로컬 대상 IP는 유효한 IPv4 주소여야 합니다.",
"invalidLocalTargetPort": "로컬 대상 포트는 1에서 65535 사이여야 합니다.",
"invalidRemoteAddress": "원격 주소가 필요합니다.",
"invalidRemotePort": "원격 포트는 1에서 65535 사이여야 합니다.",
"lastError": "마지막 오류",
"lastStarted": "마지막 시작",
"lastTested": "마지막 테스트",
"localAddress": "지역 주소",
"localPort": "로컬 포트",
"localSaveError": "로컬 클라이언트 터널 저장 실패",
"localSaved": "클라이언트 터널 저장됨",
"manualControlError": "터널 상태 업데이트 실패",
"maxRetries": "최대 재시도 횟수",
"mode": "모드",
"nextRetryIn": "{{seconds}}초 후 재시도",
"noClientTunnels": "이 데스크톱에 구성된 클라이언트 터널이 없습니다.",
"noHostSelected": "선택된 호스트 없음",
"noSshTunnels": "SSH 터널 없음",
"port": "포트",
"remoteBindAddress": "원격 바인딩 주소",
"remoteHost": "원격 호스트",
"remotePort": "원격 포트",
"retryInterval": "재시도 간격(초)",
"route": "경로:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS ({{endpoint}} 경유)",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} (경유: {{endpoint}})",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "테스트",
"tunnelName": "터널 이름",
"tunnelTestFailed": "터널 테스트 실패",
"tunnelTestSucceeded": "터널 테스트 성공",
"type": "터널 유형",
"typeDynamic": "동적 (-D)",
"typeLocal": "로컬 (-L)",
"typeRemote": "원격 (-R)",
"working": "작업 중...",
"activeCount": "{{connected}}/{{total}} 활성",
"listensOn": "localhost에 바인딩됨:{{port}}",
"socksProxy": "SOCKS5 프록시",
"waiting": "대기 중",
"remoteSessionExpired": "원격 동기화 세션이 만료되었습니다. 원격 동기화에 다시 연결하고 다시 시도하십시오.",
"activeTunnels": "활성 터널",
"start": "시작",
"stop": "중지",
"authTitle": "터널 인증",
"authRetry": "이전 코드가 허용되지 않았습니다. 새 코드를 입력하세요.",
"authAnswer": "인증 응답",
"authContinue": "계속"
},
"permissions": {
"use": {
"title": "터널을 이용하세요",
"description": "SSH 터널을 시작, 중지 및 모니터링하고 클라이언트 터널 사전 설정을 저장할 수 있습니다."
}
},
"settings": {
"host": {
"enable": {
"label": "터널 활성화",
"description": "이 호스트를 통해 SSH 터널을 설정하십시오."
}
}
},
"onboarding": {
"feature_tunnels": "터널",
"feature_tunnels_desc": "서버를 통해 포트를 포워딩하여 외부에 노출되지 않은 서비스에 접근할 수 있습니다."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Legg til tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Koble til",
"disconnectBtn": "Koble fra",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endepunktport",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Maks forsøk",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Kildeport",
"tabTunnels": "Tunneler",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunneler"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Last",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Forsøk {{current}} av {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Autostart",
"cancel": "Avbryt",
"canceling": "Avbryter...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Tilkoblet",
"connecting": "Kobler til...",
"createFirstTunnelMessage": "Du har ikke opprettet noen SSH-tunneler ennå. Konfigurer tunneltilkoblinger i Vertadministrator for å komme i gang.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Frakoblet",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endepunktport",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Feil",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Lokal port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Maks forsøk",
"mode": "Mode",
"nextRetryIn": "Neste forsøk om {{seconds}} sekunder",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "Ingen vert valgt",
"noSshTunnels": "Ingen SSH-tunneler",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Fjernvert",
"remotePort": "Fjernport",
"retryInterval": "Intervall mellom forsøk (sekunder)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnelnavn",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Venter",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stopp",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Fortsett"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunneler",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Dodaj tunel",
"autoStartDesc": "Automatycznie połącz ten tunel po załadowaniu hosta",
"autoStartLabel": "Autostart",
"connectBtn": "Połącz",
"disconnectBtn": "Odłącz",
"enableTunneling": "Włącz tunelowanie",
"enableTunnelingDesc": "Włącz funkcjonalność tunelu SSH dla tego hosta",
"endpointHost": "Host punktu końcowego",
"endpointHostPlaceholder": "Ten sam host, host SSH lub dostępny host/adres IP",
"endpointPort": "Port końcowy",
"failedToConnectTunnel": "Nie udało się połączyć",
"failedToDisconnectTunnel": "Nie udało się rozłączyć",
"maxRetries": "Maksymalna liczba ponownych prób",
"noTunnelsConfigured": "Nie skonfigurowano żadnych tuneli.",
"retryIntervalS": "Interwał ponawiania (s)",
"serverTunnelsSection": "Tunele serwerowe",
"sourcePort": "Port źródłowy",
"tabTunnels": "Tunele",
"tunnelConnecting": "Łączenie tunelu...",
"tunnelDisconnected": "Tunel rozłączony",
"tunnelLabel": "Tunel {{number}}",
"tunnelModeDynamicDesc": "Utwórz serwer proxy SOCKS5 na porcie lokalnym w celu dynamicznego przekierowywania portów.",
"tunnelModeLocalDesc": "Przekieruj port lokalny do portu na serwerze zdalnym (lub do hosta, do którego można uzyskać dostęp z tego serwera).",
"tunnelModeRemoteDesc": "Przekieruj port na serwerze zdalnym z powrotem do portu lokalnego na swoim komputerze.",
"tunnelRequirementsText": "Serwer SSH musi mieć w /etc/ssh/sshd_config ustawione GatewayPorts yes, AllowTcpForwarding yes i PermitRootLogin yes.",
"tunnelSettings": "Ustawienia tunelu",
"tunnelType": "Typ tunelu",
"bindHost": "Host nasłuchu",
"docsLink": "Zobacz dokumentację",
"hostNotFound": "Nie znaleziono hosta",
"loadingHost": "Ładowanie hosta..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunele"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (nasłuch lokalny)",
"localTargetHost": "127.0.0.1 (cel na tym komputerze)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (nasłuch SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "Liczba lokalnych tuneli klienta skonfigurowanych na tym komputerze: {{count}}.",
"c2sLoadPreset": "Wczytaj",
"c2sNoPresetSelected": "Nie wybrano zestawu",
"c2sNoPresets": "Brak zapisanych zestawów",
"c2sPresetDeleted": "Usunięto zestaw tuneli klienta",
"c2sPresetLoadError": "Nie udało się wczytać zestawów tuneli klienta",
"c2sPresetLoaded": "Wczytano zestaw tuneli klienta lokalnie",
"c2sPresetName": "Nazwa zestawu",
"c2sPresetNamePlaceholder": "Nazwa zestawu klienta",
"c2sPresetRenamed": "Zmieniono nazwę zestawu tuneli klienta",
"c2sPresetSaved": "Zapisano zestaw tuneli klienta",
"c2sPresetSyncNote": "Zestawy to jawne migawki; wczytanie zestawu zastępuje listę lokalnych tuneli klienta w tej aplikacji.",
"c2sPresetToLoad": "Zestaw do wczytania",
"c2sTunnelPresets": "Zestawy tuneli klienta",
"c2sTunnelPresetsUnavailable": "Zestawy tuneli klienta są dostępne tylko w aplikacji desktopowej.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Próba {{current}} z {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} przez {{endpoint}}",
"autoNameClientLocal": "Lokalny {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokalny {{localPort}}",
"autoStart": "Autostart",
"cancel": "Anuluj",
"canceling": "Anulowanie...",
"clientAutoStartDesc": "Uruchamia się przy starcie aplikacji desktopowej i pozostaje połączony.",
"clientManualStartDesc": "Użyj przycisków Start i Stop z tego wiersza. Termix nie otworzy go automatycznie.",
"clientRemoteServerNote": "Przekierowanie zdalne może wymagać opcji AllowTcpForwarding i GatewayPorts na serwerze SSH punktu końcowego. Port zdalny zamyka się po rozłączeniu tego komputera.",
"clientTunnelStarted": "Uruchomiono tunel klienta",
"clientTunnelStopped": "Zatrzymano tunel klienta",
"clientTunnels": "Tunele klienckie",
"connected": "Połączono",
"connecting": "Łączenie...",
"createFirstTunnelMessage": "Nie utworzono jeszcze żadnych tuneli SSH. Skonfiguruj połączenia tunelowe w Menedżerze hostów, aby zacząć.",
"currentHostPort": "Aktualny port hosta",
"destination": "Cel",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Rozłączono",
"duplicateAutoStartBind": "Tylko jeden tunel klienta z automatycznym uruchomieniem może używać {{bind}}.",
"endpointPort": "Port końcowy",
"endpointSshHost": "Host SSH punktu końcowego",
"endpointSshHostPlaceholder": "Wybierz skonfigurowanego hosta",
"endpointSshHostRequired": "Wybierz hosta SSH punktu końcowego dla każdego tunelu klienta.",
"error": "Błąd",
"forwardDescriptionClientDynamic": "SOCKS na porcie lokalnym {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokalny {{sourcePort}} → zdalny {{endpointPort}}.",
"forwardDescriptionClientRemote": "Zdalny {{sourcePort}} → lokalny {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS na bieżącym hoście {{sourcePort}}.",
"forwardDescriptionServerLocal": "Aktualny host {{sourcePort}} → punkt końcowy {{endpointPort}}.",
"forwardDescriptionServerRemote": "Punkt końcowy {{endpointPort}} → bieżący host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Lokalny adres IP musi być prawidłowym adresem IPv4.",
"invalidEndpointPort": "Port końcowy musi mieścić się w zakresie od 1 do 65535.",
"invalidLocalPort": "Port lokalny musi mieścić się w zakresie od 1 do 65535.",
"invalidLocalTargetIp": "Lokalny adres IP docelowy musi być prawidłowym adresem IPv4.",
"invalidLocalTargetPort": "Lokalny port docelowy musi mieścić się w zakresie od 1 do 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Port zdalny musi mieścić się w zakresie od 1 do 65535.",
"lastError": "Ostatni błąd",
"lastStarted": "Ostatnio uruchomiony",
"lastTested": "Ostatnio testowane",
"localAddress": "Local Address",
"localPort": "Port lokalny",
"localSaveError": "Nie udało się zapisać lokalnych tuneli klienta",
"localSaved": "Zapisano tunele klienta",
"manualControlError": "Nie udało się zaktualizować stanu tunelu.",
"maxRetries": "Maksymalna liczba ponownych prób",
"mode": "Tryb",
"nextRetryIn": "Następna próba za {{seconds}} sekund",
"noClientTunnels": "Na tym komputerze nie skonfigurowano żadnych tuneli klienckich.",
"noHostSelected": "Nie wybrano hosta",
"noSshTunnels": "Brak tuneli SSH",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Zdalny host",
"remotePort": "Zdalny port",
"retryInterval": "Interwał ponawiania (sekundy)",
"route": "Trasa:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS przez {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Nazwa tunelu",
"tunnelTestFailed": "Test tunelu nie powiódł się",
"tunnelTestSucceeded": "Test tunelu zakończony powodzeniem",
"type": "Typ tunelu",
"typeDynamic": "Dynamiczny (-D)",
"typeLocal": "Lokalny (-L)",
"typeRemote": "Zdalny (-R)",
"working": "Przetwarzanie...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Aktywne tunele",
"start": "Start",
"stop": "Zatrzymaj",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Kontynuuj"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunele",
"feature_tunnels_desc": "Przekierowuj porty przez serwer, aby dotrzeć do usług niewystawionych na zewnątrz."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Conectar",
"disconnectBtn": "Desconectar",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Túneis",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "Documentação",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Túneis"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancelar",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Conectando...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Erro",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Porta",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Iniciar",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continuar"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Túneis",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Adicionar túnel",
"autoStartDesc": "Conectar automaticamente este túnel quando o host for carregado",
"autoStartLabel": "Início Automático",
"connectBtn": "Ligar",
"disconnectBtn": "Desligar",
"enableTunneling": "Ativar tunelamento",
"enableTunnelingDesc": "Ativar a funcionalidade de túnel SSH para este host",
"endpointHost": "Host de Destino",
"endpointHostPlaceholder": "Mesmo host, host SSH ou host/IP acessível",
"endpointPort": "Porta de Destino",
"failedToConnectTunnel": "Falha ao conectar",
"failedToDisconnectTunnel": "Falha ao desligar",
"maxRetries": "Tentativas Máximas",
"noTunnelsConfigured": "Nenhum túnel configurado.",
"retryIntervalS": "Intervalo de Repetição (s)",
"serverTunnelsSection": "Túneis do servidor",
"sourcePort": "Porta de Origem",
"tabTunnels": "Túneis",
"tunnelConnecting": "Túnel a conectar...",
"tunnelDisconnected": "Túnel desligado",
"tunnelLabel": "Túnel {{number}}",
"tunnelModeDynamicDesc": "Criar um proxy SOCKS5 numa porta local para reencaminhamento dinâmico de portas.",
"tunnelModeLocalDesc": "Reencaminhar uma porta local para uma porta no servidor remoto (ou um host acessível a partir dele).",
"tunnelModeRemoteDesc": "Reencaminhar uma porta no servidor remoto de volta para uma porta local na sua máquina.",
"tunnelRequirementsText": "O servidor SSH deve ter GatewayPorts yes, AllowTcpForwarding yes e PermitRootLogin yes definidos em /etc/ssh/sshd_config.",
"tunnelSettings": "Definições de túnel",
"tunnelType": "Tipo de túnel",
"bindHost": "Host de Ligação",
"docsLink": "Ver documentação",
"hostNotFound": "Host não encontrado",
"loadingHost": "A carregar o host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Túneis"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (escuta localmente)",
"localTargetHost": "127.0.0.1 (alvo neste computador)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (escuta SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} túnel/túneis de cliente local configurado(s) neste ambiente de trabalho.",
"c2sLoadPreset": "Carregar",
"c2sNoPresetSelected": "Nenhuma predefinição selecionada",
"c2sNoPresets": "Nenhuma predefinição guardada",
"c2sPresetDeleted": "Predefinição de túnel do cliente eliminada",
"c2sPresetLoadError": "Falha ao carregar as predefinições de túneis do cliente",
"c2sPresetLoaded": "Predefinição de túnel do cliente carregada localmente",
"c2sPresetName": "Nome da predefinição",
"c2sPresetNamePlaceholder": "Nome da predefinição do cliente",
"c2sPresetRenamed": "Predefinição de túnel do cliente renomeada",
"c2sPresetSaved": "Predefinição de túnel do cliente guardada",
"c2sPresetSyncNote": "As predefinições são instantâneos explícitos; carregar uma substitui a lista de túneis de cliente local deste cliente de ambiente de trabalho.",
"c2sPresetToLoad": "Predefinição a carregar",
"c2sTunnelPresets": "Predefinições de túneis do cliente",
"c2sTunnelPresetsUnavailable": "As predefinições de túneis do cliente estão disponíveis apenas no cliente de ambiente de trabalho.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Tentativa {{current}} de {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Arranque automático",
"cancel": "Cancelar",
"canceling": "A cancelar...",
"clientAutoStartDesc": "Inicia quando este cliente de desktop abre e permanece ligado.",
"clientManualStartDesc": "Utilize Iniciar e Parar nesta linha. O Termix não o abrirá automaticamente.",
"clientRemoteServerNote": "O reencaminhamento remoto pode exigir AllowTcpForwarding e GatewayPorts no servidor SSH do endpoint. A porta remota fecha quando este desktop se desliga.",
"clientTunnelStarted": "Túnel de cliente iniciado",
"clientTunnelStopped": "Túnel de cliente parado",
"clientTunnels": "Túneis de cliente",
"connected": "Ligado",
"connecting": "A ligar...",
"createFirstTunnelMessage": "Ainda não criou nenhum túnel SSH. Configure as ligações de túnel no Gestor de Anfitriões para começar.",
"currentHostPort": "Porta do host atual",
"destination": "Destino",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Desligado",
"duplicateAutoStartBind": "Apenas um túnel de cliente com arranque automático pode utilizar {{bind}}.",
"endpointPort": "Porta do endpoint",
"endpointSshHost": "Host SSH do endpoint",
"endpointSshHostPlaceholder": "Selecionar um host configurado",
"endpointSshHostRequired": "Selecione um host SSH do endpoint para cada túnel de cliente.",
"error": "Erro",
"forwardDescriptionClientDynamic": "SOCKS na porta local {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remoto {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remoto {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS no host atual {{sourcePort}}.",
"forwardDescriptionServerLocal": "Host atual {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → host atual {{sourcePort}}.",
"host": "Servidor",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "O IP local deve ser um endereço IPv4 válido.",
"invalidEndpointPort": "A porta do endpoint deve estar entre 1 e 65535.",
"invalidLocalPort": "A porta local deve estar entre 1 e 65535.",
"invalidLocalTargetIp": "O IP de destino local deve ser um endereço IPv4 válido.",
"invalidLocalTargetPort": "A porta de destino local deve estar entre 1 e 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "A porta remota deve estar entre 1 e 65535.",
"lastError": "Último erro",
"lastStarted": "Último início",
"lastTested": "Último teste",
"localAddress": "Local Address",
"localPort": "Porta local",
"localSaveError": "Falha ao guardar os túneis de cliente locais",
"localSaved": "Túneis de cliente guardados",
"manualControlError": "Falha ao atualizar o estado do túnel.",
"maxRetries": "Máx. tentativas",
"mode": "Modo",
"nextRetryIn": "Próxima tentativa em {{seconds}} segundos",
"noClientTunnels": "Nenhum túnel de cliente configurado neste desktop.",
"noHostSelected": "Nenhum servidor selecionado",
"noSshTunnels": "Sem túneis SSH",
"port": "Porta",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Host remoto",
"remotePort": "Porta remota",
"retryInterval": "Intervalo de repetição (segundos)",
"route": "Rota:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Testar",
"tunnelName": "Nome do túnel",
"tunnelTestFailed": "Teste do túnel falhou",
"tunnelTestSucceeded": "Teste do túnel bem-sucedido",
"type": "Tipo de Túnel",
"typeDynamic": "Dinâmico (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remoto (-R)",
"working": "A trabalhar...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Túneis Ativos",
"start": "Iniciar",
"stop": "Parar",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continuar"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Túneis",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Adăugați tunel",
"autoStartDesc": "Conectează automat acest tunel când gazda este încărcată",
"autoStartLabel": "Pornire automată",
"connectBtn": "Conectează-te",
"disconnectBtn": "Deconecta",
"enableTunneling": "Activează tunelarea",
"enableTunnelingDesc": "Activează funcționalitatea tunelului SSH pentru această gazdă",
"endpointHost": "Gazdă terminală",
"endpointHostPlaceholder": "Aceeași gazdă, gazdă SSH sau gazdă/IP accesibil",
"endpointPort": "Portul punctului final",
"failedToConnectTunnel": "Conectarea nu a reușit",
"failedToDisconnectTunnel": "Deconectare eșuată",
"maxRetries": "Număr maxim de reîncercări",
"noTunnelsConfigured": "Niciun tunel configurat.",
"retryIntervalS": "Interval de reîncercare (s)",
"serverTunnelsSection": "Tuneluri de server",
"sourcePort": "Port sursă",
"tabTunnels": "Tuneluri",
"tunnelConnecting": "Tunel care conectează...",
"tunnelDisconnected": "Tunel deconectat",
"tunnelLabel": "Tunel {{number}}",
"tunnelModeDynamicDesc": "Creați un proxy SOCKS5 pe un port local pentru redirecționarea dinamică a porturilor.",
"tunnelModeLocalDesc": "Redirecționează un port local către un port de pe serverul la distanță (sau o gazdă accesibilă de pe acesta).",
"tunnelModeRemoteDesc": "Redirecționează un port de pe serverul la distanță înapoi către un port local de pe mașina ta.",
"tunnelRequirementsText": "Serverul SSH trebuie să aibă setate GatewayPorts da, AllowTcpForwarding da și PermitRootLogin da în /etc/ssh/sshd_config.",
"tunnelSettings": "Setări tunel",
"tunnelType": "Tipul de tunel",
"bindHost": "Legați gazda",
"docsLink": "Vizualizați documentele",
"hostNotFound": "Gazdă negăsită",
"loadingHost": "Se încarcă gazda..."
},
"nav": {
"portForwarding": "Redirecționare",
"tunnels": "Tuneluri"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (ascultați local)",
"localTargetHost": "127.0.0.1 (ținta pe acest computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (ascultă pe endpoint)",
"remoteTargetHost": "127.0.0.1 (ținta de la punctul final)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (ascultător SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} tunel(uri) client local configurat(e) pe acest desktop.",
"c2sLoadPreset": "Încărca",
"c2sNoPresetSelected": "Nicio presetare selectată",
"c2sNoPresets": "Nicio presetare salvată",
"c2sPresetDeleted": "Presetarea tunelului clientului a fost ștearsă",
"c2sPresetLoadError": "Presetările tunelului clientului nu au putut fi încărcate.",
"c2sPresetLoaded": "Presetarea tunelului clientului a fost încărcată local",
"c2sPresetName": "Nume presetat",
"c2sPresetNamePlaceholder": "Numele presetat al clientului",
"c2sPresetRenamed": "Presetarea tunelului clientului a fost redenumită",
"c2sPresetSaved": "Presetarea tunelului clientului a fost salvată",
"c2sPresetSyncNote": "Presetările sunt instantanee explicite; încărcarea uneia înlocuiește lista de tuneluri de clienți locali a acestui client desktop.",
"c2sPresetToLoad": "Presetare pentru încărcare",
"c2sTunnelPresets": "Presetări tunel client",
"c2sTunnelPresetsUnavailable": "Presetările tunelului client sunt disponibile numai în clientul desktop.",
"c2sPresetRenameError": "Nu s-a putut redenumi presetarea",
"c2sPresetDeleteError": "Nu s-a putut șterge presetarea"
},
"tunnels": {
"attempt": "Încercare {{current}} din {{max}}",
"autoNameClientDynamic": "ȘOSETE {{localPort}} prin {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Pornire automată",
"cancel": "Anula",
"canceling": "Se anulează...",
"clientAutoStartDesc": "Pornește când se deschide acest client desktop și rămâne conectat.",
"clientManualStartDesc": "Folosește Start și Stop de pe acest rând. Termix nu îl va deschide automat.",
"clientRemoteServerNote": "Redirecționarea la distanță poate necesita AllowTcpForwarding și GatewayPorts pe serverul SSH al terminalului. Portul la distanță se închide când acest desktop se deconectează.",
"clientTunnelStarted": "Tunelul clientului a fost pornit",
"clientTunnelStopped": "Tunelul clientului s-a oprit",
"clientTunnels": "Tuneluri client",
"connected": "Conectat",
"connecting": "Se conectează...",
"createFirstTunnelMessage": "Nu ați creat încă niciun tunel SSH. Configurați conexiunile la tunel în Host Manager pentru a începe.",
"currentHostPort": "Portul gazdă curent",
"destination": "Destinaţie",
"destinationAddress": "Adresă de destinație",
"destinationPort": "Port de destinație",
"disconnected": "Deconectat",
"duplicateAutoStartBind": "Doar un tunel client cu pornire automată poate utiliza {{bind}}.",
"endpointPort": "Portul punctului final",
"endpointSshHost": "Gazdă SSH pentru punct final",
"endpointSshHostPlaceholder": "Selectați o gazdă configurată",
"endpointSshHostRequired": "Selectați o gazdă SSH endpoint pentru fiecare tunel client.",
"error": "Eroare",
"forwardDescriptionClientDynamic": "ȘOSETE pe portul local {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → la distanță {{endpointPort}}.",
"forwardDescriptionClientRemote": "La distanță {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "ȘOSETE pe gazda curentă {{sourcePort}}.",
"forwardDescriptionServerLocal": "Gazdă curentă {{sourcePort}} → punct final {{endpointPort}}.",
"forwardDescriptionServerRemote": "Punct final {{endpointPort}} → gazdă curentă {{sourcePort}}.",
"host": "Gazdă",
"intermediateHost": "Gazdă intermediară",
"invalidBindIp": "Adresa IP locală trebuie să fie o adresă IPv4 validă.",
"invalidEndpointPort": "Portul punctului final trebuie să fie între 1 și 65535.",
"invalidLocalPort": "Portul local trebuie să fie între 1 și 65535.",
"invalidLocalTargetIp": "Adresa IP locală țintă trebuie să fie o adresă IPv4 validă.",
"invalidLocalTargetPort": "Portul țintă local trebuie să fie între 1 și 65535.",
"invalidRemoteAddress": "Este necesară adresa la distanță.",
"invalidRemotePort": "Portul la distanță trebuie să fie între 1 și 65535.",
"lastError": "Ultima eroare",
"lastStarted": "Ultima pornire",
"lastTested": "Ultima testare",
"localAddress": "Adresă locală",
"localPort": "Port local",
"localSaveError": "Nu s-au putut salva tunelurile clientului local",
"localSaved": "Tunelurile clientului au fost salvate",
"manualControlError": "Nu s-a putut actualiza starea tunelului.",
"maxRetries": "Număr maxim de reîncercări",
"mode": "Mod",
"nextRetryIn": "Următoarea reîncercare în {{seconds}} secunde",
"noClientTunnels": "Nu sunt configurate tuneluri client pe acest desktop.",
"noHostSelected": "Nicio gazdă selectată",
"noSshTunnels": "Fără tuneluri SSH",
"port": "Port",
"remoteBindAddress": "Adresă de legare la distanță",
"remoteHost": "Gazdă la distanță",
"remotePort": "Port la distanță",
"retryInterval": "Interval de reîncercare (secunde)",
"route": "Traseu:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → ȘOSETE prin {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} prin {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} pe {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Numele tunelului",
"tunnelTestFailed": "Testul tunelului a eșuat",
"tunnelTestSucceeded": "Testul tunelului a reușit",
"type": "Tipul de tunel",
"typeDynamic": "Dinamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Telecomandă (-R)",
"working": "Lucru...",
"activeCount": "{{connected}}/{{total}} activ",
"listensOn": "Legat la localhost:{{port}}",
"socksProxy": "Proxy SOCKS5",
"waiting": "Aşteptare",
"remoteSessionExpired": "Sesiunea de sincronizare la distanță a expirat. Reconectați sincronizarea la distanță și încercați din nou.",
"activeTunnels": "Tuneluri active",
"start": "Început",
"stop": "Stop",
"authTitle": "Autentificarea tunelului",
"authRetry": "Codul anterior nu a fost acceptat. Introduceți un cod nou.",
"authAnswer": "Răspuns de autentificare",
"authContinue": "Continua"
},
"permissions": {
"use": {
"title": "Folosește tuneluri",
"description": "Porniți, opriți și urmăriți tunelurile SSH și salvați presetările tunelului client."
}
},
"settings": {
"host": {
"enable": {
"label": "Activează tunelurile",
"description": "Configurați tuneluri SSH prin această gazdă."
}
}
},
"onboarding": {
"feature_tunnels": "Tuneluri",
"feature_tunnels_desc": "Redirecționează porturile printr-un server pentru a accesa servicii care nu sunt expuse."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Добавить туннель",
"autoStartDesc": "Автоматически подключать этот туннель при загрузке хоста",
"autoStartLabel": "Автозапуск",
"connectBtn": "Подключиться",
"disconnectBtn": "Отключиться",
"enableTunneling": "Включить туннелирование",
"enableTunnelingDesc": "Включить функциональность SSH-туннеля для этого хоста",
"endpointHost": "Целевой хост",
"endpointHostPlaceholder": "Тот же хост, SSH-хост или доступный хост/IP-адрес",
"endpointPort": "Целевой порт",
"failedToConnectTunnel": "Не удалось подключиться",
"failedToDisconnectTunnel": "Не удалось отключиться",
"maxRetries": "Макс. число попыток",
"noTunnelsConfigured": "Туннели не настроены.",
"retryIntervalS": "Интервал повтора (с)",
"serverTunnelsSection": "Серверные туннели",
"sourcePort": "Исходный порт",
"tabTunnels": "Туннели",
"tunnelConnecting": "Туннель подключается...",
"tunnelDisconnected": "Туннель отключён",
"tunnelLabel": "Туннель {{number}}",
"tunnelModeDynamicDesc": "Создать SOCKS5-прокси на локальном порту для динамического пробрасывания портов.",
"tunnelModeLocalDesc": "Пробросить локальный порт на порт удалённого сервера (или хоста, доступного с него).",
"tunnelModeRemoteDesc": "Пробросить порт на удалённом сервере на локальный порт вашей машины.",
"tunnelRequirementsText": "На SSH-сервере должны быть установлены параметры GatewayPorts yes, AllowTcpForwarding yes и PermitRootLogin yes в /etc/ssh/sshd_config.",
"tunnelSettings": "Настройки туннеля",
"tunnelType": "Тип туннеля",
"bindHost": "Хост для привязки",
"docsLink": "Документация",
"hostNotFound": "Хост не найден",
"loadingHost": "Загрузка хоста..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Туннели"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (слушать локально)",
"localTargetHost": "127.0.0.1 (цель на этом компьютере)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-слушатель)"
},
"profile": {
"c2sCurrentLocalConfig": "На этом рабочем столе настроено {{count}} локальных клиентских туннелей.",
"c2sLoadPreset": "Загрузить",
"c2sNoPresetSelected": "Предустановка не выбрана",
"c2sNoPresets": "Нет сохраненных предустановок",
"c2sPresetDeleted": "Предустановка клиентского туннеля удалена",
"c2sPresetLoadError": "Не удалось загрузить предустановки клиентских туннелей",
"c2sPresetLoaded": "Предустановка клиентского туннеля загружена локально",
"c2sPresetName": "Имя предустановки",
"c2sPresetNamePlaceholder": "Имя клиентской предустановки",
"c2sPresetRenamed": "Предустановка клиентского туннеля переименована",
"c2sPresetSaved": "Предустановка клиентского туннеля сохранена",
"c2sPresetSyncNote": "Предустановки — это явные снимки; загрузка одной заменяет список локальных клиентских туннелей этого десктопного клиента.",
"c2sPresetToLoad": "Предустановка для загрузки",
"c2sTunnelPresets": "Предустановки клиентских туннелей",
"c2sTunnelPresetsUnavailable": "Предустановки клиентских туннелей доступны только в десктопном клиенте.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Попытка {{current}} из {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} через {{endpoint}}",
"autoNameClientLocal": "Локальный {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → локальный {{localPort}}",
"autoStart": "Автозапуск",
"cancel": "Отмена",
"canceling": "Отмена...",
"clientAutoStartDesc": "Запускается при открытии этого клиента рабочего стола и остается подключенным.",
"clientManualStartDesc": "Используйте «Запустить» и «Остановить» в этой строке. Termix не будет запускать его автоматически.",
"clientRemoteServerNote": "Удаленная переадресация может потребовать AllowTcpForwarding и GatewayPorts на конечном SSH-сервере. Удаленный порт закрывается при отключении этого рабочего стола.",
"clientTunnelStarted": "Клиентский туннель запущен",
"clientTunnelStopped": "Клиентский туннель остановлен",
"clientTunnels": "Клиентские туннели",
"connected": "Подключено",
"connecting": "Подключение...",
"createFirstTunnelMessage": "Вы ещё не создали ни одного SSH-туннеля. Настройте туннельные соединения в Менеджере хостов, чтобы начать.",
"currentHostPort": "Порт текущего хоста",
"destination": "Назначение",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Отключено",
"duplicateAutoStartBind": "Только один клиентский туннель с автозапуском может использовать {{bind}}.",
"endpointPort": "Порт конечной точки",
"endpointSshHost": "Хост SSH конечной точки",
"endpointSshHostPlaceholder": "Выберите настроенный хост",
"endpointSshHostRequired": "Выберите хост SSH конечной точки для каждого клиентского туннеля.",
"error": "Ошибка",
"forwardDescriptionClientDynamic": "SOCKS на локальном порту {{sourcePort}}.",
"forwardDescriptionClientLocal": "Локальный {{sourcePort}} → удалённый {{endpointPort}}.",
"forwardDescriptionClientRemote": "Удалённый {{sourcePort}} → локальный {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS на текущем хосте {{sourcePort}}.",
"forwardDescriptionServerLocal": "Текущий хост {{sourcePort}} → конечная точка {{endpointPort}}.",
"forwardDescriptionServerRemote": "Конечная точка {{endpointPort}} → текущий хост {{sourcePort}}.",
"host": "Хост",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Локальный IP-адрес должен быть действительным адресом IPv4.",
"invalidEndpointPort": "Порт конечной точки должен быть в диапазоне от 1 до 65535.",
"invalidLocalPort": "Локальный порт должен быть в диапазоне от 1 до 65535.",
"invalidLocalTargetIp": "Локальный целевой IP-адрес должен быть действительным адресом IPv4.",
"invalidLocalTargetPort": "Локальный целевой порт должен быть в диапазоне от 1 до 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Удаленный порт должен быть в диапазоне от 1 до 65535.",
"lastError": "Последняя ошибка",
"lastStarted": "Последний запуск",
"lastTested": "Последняя проверка",
"localAddress": "Local Address",
"localPort": "Локальный порт",
"localSaveError": "Не удалось сохранить локальные клиентские туннели",
"localSaved": "Клиентские туннели сохранены",
"manualControlError": "Не удалось обновить состояние туннеля.",
"maxRetries": "Макс. попыток",
"mode": "Режим",
"nextRetryIn": "Следующая попытка через {{seconds}} сек.",
"noClientTunnels": "На этом рабочем столе не настроено ни одного клиентского туннеля.",
"noHostSelected": "Хост не выбран",
"noSshTunnels": "Нет SSH-туннелей",
"port": "Порт",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Удаленный хост",
"remotePort": "Удаленный порт",
"retryInterval": "Интервал повтора (секунд)",
"route": "Маршрут:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS через {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Проверить",
"tunnelName": "Имя туннеля",
"tunnelTestFailed": "Проверка туннеля не удалась",
"tunnelTestSucceeded": "Проверка туннеля успешна",
"type": "Тип туннеля",
"typeDynamic": "Динамический (-D)",
"typeLocal": "Локальный (-L)",
"typeRemote": "Удалённый (-R)",
"working": "Выполняется...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Активные туннели",
"start": "Запустить",
"stop": "Остановить",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Продолжить"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Туннели",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Tünel Ekle",
"autoStartDesc": "Ana bilgisayar yüklendiğinde bu tüneli otomatik olarak bağla",
"autoStartLabel": "Otomatik başlat",
"connectBtn": "Bağlan",
"disconnectBtn": "Bağlantıyı Kes",
"enableTunneling": "Tünellemeyi Etkinleştir",
"enableTunnelingDesc": "Bu ana bilgisayar için SSH tünel işlevselliğini etkinleştir",
"endpointHost": "Hedef Ana Bilgisayar",
"endpointHostPlaceholder": "Aynı ana bilgisayar, SSH ana bilgisayarı veya erişilebilir ana bilgisayar/IP",
"endpointPort": "Hedef Port",
"failedToConnectTunnel": "Bağlantı başarısız oldu",
"failedToDisconnectTunnel": "Bağlantı kesme başarısız oldu",
"maxRetries": "Maksimum Yeniden Deneme",
"noTunnelsConfigured": "Yapılandırılmış tünel yok.",
"retryIntervalS": "Yeniden Deneme Aralığı (sn)",
"serverTunnelsSection": "Sunucu Tünelleri",
"sourcePort": "Kaynak Port",
"tabTunnels": "Tüneller",
"tunnelConnecting": "Tünel bağlanıyor...",
"tunnelDisconnected": "Tünel bağlantısı kesildi",
"tunnelLabel": "Tünel {{number}}",
"tunnelModeDynamicDesc": "Dinamik port yönlendirme için yerel bir portta bir SOCKS5 proxy oluşturun.",
"tunnelModeLocalDesc": "Yerel bir portu uzak sunucudaki bir porta (veya ondan erişilebilen bir sunucuya) yönlendirin.",
"tunnelModeRemoteDesc": "Uzak sunucudaki bir portu, makinenizdeki yerel bir porta geri yönlendirin.",
"tunnelRequirementsText": "SSH sunucusunda /etc/ssh/sshd_config dosyasında GatewayPorts yes, AllowTcpForwarding yes ve PermitRootLogin yes ayarlanmış olmalıdır.",
"tunnelSettings": "Tünel Ayarları",
"tunnelType": "Tünel Türü",
"bindHost": "Bağlanma Ana Bilgisayarı",
"docsLink": "Dokümanları görüntüle",
"hostNotFound": "Host bulunamadı",
"loadingHost": "Host yükleniyor..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tüneller"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (yerel olarak dinle)",
"localTargetHost": "127.0.0.1 (bu bilgisayardaki hedef)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS dinleyici)"
},
"profile": {
"c2sCurrentLocalConfig": "Bu masaüstünde yapılandırılmış {{count}} yerel istemci tüneli.",
"c2sLoadPreset": "Yükle",
"c2sNoPresetSelected": "Ön ayar seçilmedi",
"c2sNoPresets": "Kayıtlı ön ayar yok",
"c2sPresetDeleted": "İstemci tünel ön ayarı silindi",
"c2sPresetLoadError": "İstemci tünel ön ayarları yüklenemedi",
"c2sPresetLoaded": "İstemci tünel ön ayarı yerel olarak yüklendi",
"c2sPresetName": "Ön Ayar Adı",
"c2sPresetNamePlaceholder": "İstemci ön ayar adı",
"c2sPresetRenamed": "İstemci tünel ön ayarı yeniden adlandırıldı",
"c2sPresetSaved": "İstemci tünel ön ayarı kaydedildi",
"c2sPresetSyncNote": "Ön ayarlar belirli anlık görüntülerdir; birini yüklemek, bu masaüstü istemcinin yerel istemci tünel listesini değiştirir.",
"c2sPresetToLoad": "Yüklenecek Ön Ayar",
"c2sTunnelPresets": "İstemci Tünel Ön Ayarları",
"c2sTunnelPresetsUnavailable": "İstemci tünel ön ayarları yalnızca masaüstü istemcisinde kullanılabilir.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Deneme {{current}}/{{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} üzerinden {{endpoint}}",
"autoNameClientLocal": "Yerel {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → yerel {{localPort}}",
"autoStart": "Otomatik Başlat",
"cancel": "İptal",
"canceling": "İptal ediliyor...",
"clientAutoStartDesc": "Bu masaüstü istemcisi açıldığında başlar ve bağlı kalır.",
"clientManualStartDesc": "Bu satırdaki Başlat ve Durdur'u kullanın. Termix otomatik olarak açmaz.",
"clientRemoteServerNote": "Uzak yönlendirme, uç nokta SSH sunucusunda AllowTcpForwarding ve GatewayPorts gerektirebilir. Bu masaüstü bağlantısı kesildiğinde uzak port kapanır.",
"clientTunnelStarted": "İstemci tüneli başlatıldı",
"clientTunnelStopped": "İstemci tüneli durduruldu",
"clientTunnels": "İstemci Tünelleri",
"connected": "Bağlı",
"connecting": "Bağlanıyor...",
"createFirstTunnelMessage": "Henüz hiç SSH tüneli oluşturmadınız. Başlamak için Ana Bilgisayar Yöneticisi'nde tünel bağlantılarını yapılandırın.",
"currentHostPort": "Geçerli Sunucu Portu",
"destination": "Hedef",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Bağlantı kesildi",
"duplicateAutoStartBind": "Yalnızca bir otomatik başlatmalı istemci tüneli {{bind}} kullanabilir.",
"endpointPort": "Uç Nokta Portu",
"endpointSshHost": "Uç Nokta SSH Sunucusu",
"endpointSshHostPlaceholder": "Yapılandırılmış bir sunucu seçin",
"endpointSshHostRequired": "Her istemci tüneli için bir uç nokta SSH sunucusu seçin.",
"error": "Hata",
"forwardDescriptionClientDynamic": "Yerel port {{sourcePort}} üzerinde SOCKS.",
"forwardDescriptionClientLocal": "Yerel {{sourcePort}} → uzak {{endpointPort}}.",
"forwardDescriptionClientRemote": "Uzak {{sourcePort}} → yerel {{endpointPort}}.",
"forwardDescriptionServerDynamic": "Geçerli sunucuda SOCKS {{sourcePort}}.",
"forwardDescriptionServerLocal": "Geçerli sunucu {{sourcePort}} → uç nokta {{endpointPort}}.",
"forwardDescriptionServerRemote": "Uç nokta {{endpointPort}} → geçerli sunucu {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Yerel IP geçerli bir IPv4 adresi olmalıdır.",
"invalidEndpointPort": "Uç nokta portu 1 ile 65535 arasında olmalıdır.",
"invalidLocalPort": "Yerel port 1 ile 65535 arasında olmalıdır.",
"invalidLocalTargetIp": "Yerel hedef IP geçerli bir IPv4 adresi olmalıdır.",
"invalidLocalTargetPort": "Yerel hedef portu 1 ile 65535 arasında olmalıdır.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Uzak port 1 ile 65535 arasında olmalıdır.",
"lastError": "Son hata",
"lastStarted": "Son başlatma",
"lastTested": "Son test",
"localAddress": "Local Address",
"localPort": "Yerel Port",
"localSaveError": "Yerel istemci tünelleri kaydedilemedi",
"localSaved": "İstemci tünelleri kaydedildi",
"manualControlError": "Tünel durumu güncellenemedi.",
"maxRetries": "Maks. Yeniden Deneme",
"mode": "Mod",
"nextRetryIn": "Sonraki deneme {{seconds}} saniye sonra",
"noClientTunnels": "Bu masaüstünde hiçbir istemci tüneli yapılandırılmamış.",
"noHostSelected": "Hiçbir host seçilmedi",
"noSshTunnels": "SSH Tüneli yok",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Uzak Sunucu",
"remotePort": "Uzak Port",
"retryInterval": "Yeniden Deneme Aralığı (saniye)",
"route": "Rota:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → {{endpoint}} üzerinden SOCKS",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test Et",
"tunnelName": "Tünel Adı",
"tunnelTestFailed": "Tünel testi başarısız",
"tunnelTestSucceeded": "Tünel testi başarılı",
"type": "Tünel Türü",
"typeDynamic": "Dinamik (-D)",
"typeLocal": "Yerel (-L)",
"typeRemote": "Uzak (-R)",
"working": "Çalışıyor...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Etkin Tüneller",
"start": "Başlat",
"stop": "Durdur",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Devam"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tüneller",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Add Tunnel",
"autoStartDesc": "Automatically connect this tunnel when the host is loaded",
"autoStartLabel": "Auto-start",
"connectBtn": "Connect",
"disconnectBtn": "Disconnect",
"enableTunneling": "Enable Tunneling",
"enableTunnelingDesc": "Enable SSH tunnel functionality for this host",
"endpointHost": "Endpoint Host",
"endpointHostPlaceholder": "Same host, SSH host, or reachable host/IP",
"endpointPort": "Endpoint Port",
"failedToConnectTunnel": "Failed to connect",
"failedToDisconnectTunnel": "Failed to disconnect",
"maxRetries": "Max Retries",
"noTunnelsConfigured": "No tunnels configured.",
"retryIntervalS": "Retry Interval (s)",
"serverTunnelsSection": "Server Tunnels",
"sourcePort": "Source Port",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnel connecting...",
"tunnelDisconnected": "Tunnel disconnected",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Create a SOCKS5 proxy on a local port for dynamic port forwarding.",
"tunnelModeLocalDesc": "Forward a local port to a port on the remote server (or a host reachable from it).",
"tunnelModeRemoteDesc": "Forward a port on the remote server back to a local port on your machine.",
"tunnelRequirementsText": "The SSH server must have GatewayPorts yes, AllowTcpForwarding yes, and PermitRootLogin yes set in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnel Settings",
"tunnelType": "Tunnel Type",
"bindHost": "Bind Host",
"docsLink": "View docs",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS listener)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sLoadPreset": "Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetToLoad": "Preset To Load",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Attempt {{current}} of {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Auto Start",
"cancel": "Cancel",
"canceling": "Canceling...",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"clientTunnels": "Client Tunnels",
"connected": "Connected",
"connecting": "Connecting...",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"currentHostPort": "Current Host Port",
"destination": "Destination",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Disconnected",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"endpointPort": "Endpoint Port",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"error": "Error",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"host": "Host",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"lastError": "Last error",
"lastStarted": "Last started",
"lastTested": "Last tested",
"localAddress": "Local Address",
"localPort": "Local Port",
"localSaveError": "Failed to save local client tunnels",
"localSaved": "Client tunnels saved",
"manualControlError": "Failed to update tunnel state.",
"maxRetries": "Max Retries",
"mode": "Mode",
"nextRetryIn": "Next retry in {{seconds}} seconds",
"noClientTunnels": "No client tunnels configured on this desktop.",
"noHostSelected": "No host selected",
"noSshTunnels": "No SSH Tunnels",
"port": "Port",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Remote Host",
"remotePort": "Remote Port",
"retryInterval": "Retry Interval (seconds)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnel Name",
"tunnelTestFailed": "Tunnel test failed",
"tunnelTestSucceeded": "Tunnel test succeeded",
"type": "Tunnel Type",
"typeDynamic": "Dynamic (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"working": "Working...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Active Tunnels",
"start": "Start",
"stop": "Stop",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Continue"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "Thêm đường hầm",
"autoStartDesc": "Tự động kết nối đường hầm này khi máy chủ được tải",
"autoStartLabel": "Tự động khởi động",
"connectBtn": "Kết nối",
"disconnectBtn": "Ngắt kết nối",
"enableTunneling": "Kích hoạt đường hầm",
"enableTunnelingDesc": "Kích hoạt chức năng đường hầm SSH cho máy chủ này",
"endpointHost": "Máy chủ đích",
"endpointHostPlaceholder": "Cùng máy chủ, máy chủ SSH, hoặc máy chủ/IP có thể truy cập",
"endpointPort": "Cổng đích",
"failedToConnectTunnel": "Kết nối thất bại",
"failedToDisconnectTunnel": "Ngắt kết nối thất bại",
"maxRetries": "Số lần thử lại tối đa",
"noTunnelsConfigured": "Chưa cấu hình đường hầm nào.",
"retryIntervalS": "Khoảng thời gian thử lại (giây)",
"serverTunnelsSection": "Đường hầm máy chủ",
"sourcePort": "Cổng nguồn",
"tabTunnels": "Đường hầm",
"tunnelConnecting": "Đang kết nối đường hầm...",
"tunnelDisconnected": "Đã ngắt kết nối đường hầm",
"tunnelLabel": "Đường hầm {{number}}",
"tunnelModeDynamicDesc": "Tạo proxy SOCKS5 trên một cổng cục bộ để chuyển tiếp cổng động.",
"tunnelModeLocalDesc": "Chuyển tiếp một cổng cục bộ đến một cổng trên máy chủ từ xa (hoặc máy chủ có thể truy cập từ đó).",
"tunnelModeRemoteDesc": "Chuyển tiếp một cổng trên máy chủ từ xa về một cổng cục bộ trên máy của bạn.",
"tunnelRequirementsText": "Máy chủ SSH phải có GatewayPorts yes, AllowTcpForwarding yes, và PermitRootLogin yes được đặt trong /etc/ssh/sshd_config.",
"tunnelSettings": "Cài đặt đường hầm",
"tunnelType": "Loại đường hầm",
"bindHost": "Máy chủ bind",
"docsLink": "Xem tài liệu",
"hostNotFound": "Không tìm thấy máy chủ",
"loadingHost": "Đang tải máy chủ..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "Đường hầm"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lắng nghe cục bộ)",
"localTargetHost": "127.0.0.1 (đích trên máy này)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (Trình nghe SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} đường hầm máy khách cục bộ đã được cấu hình trên máy tính này.",
"c2sLoadPreset": "Tải",
"c2sNoPresetSelected": "Chưa chọn cài đặt sẵn",
"c2sNoPresets": "Chưa lưu cài đặt sẵn nào",
"c2sPresetDeleted": "Đã xóa cài đặt sẵn đường hầm máy khách",
"c2sPresetLoadError": "Tải cài đặt sẵn đường hầm máy khách thất bại",
"c2sPresetLoaded": "Đã tải cài đặt sẵn đường hầm máy khách cục bộ",
"c2sPresetName": "Tên cài đặt sẵn",
"c2sPresetNamePlaceholder": "Tên cài đặt sẵn máy khách",
"c2sPresetRenamed": "Đã đổi tên cài đặt sẵn đường hầm máy khách",
"c2sPresetSaved": "Đã lưu cài đặt sẵn đường hầm máy khách",
"c2sPresetSyncNote": "Cài đặt sẵn là ảnh chụp tức thời; tải một cài đặt sẽ thay thế danh sách đường hầm máy khách cục bộ trên máy này.",
"c2sPresetToLoad": "Cài đặt sẵn để tải",
"c2sTunnelPresets": "Cài đặt sẵn Đường hầm Máy khách",
"c2sTunnelPresetsUnavailable": "Cài đặt sẵn đường hầm máy khách chỉ khả dụng trên ứng dụng máy tính.",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "Lần thử {{current}} / {{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} qua {{endpoint}}",
"autoNameClientLocal": "Cục bộ {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → cục bộ {{localPort}}",
"autoStart": "Tự động khởi động",
"cancel": "Hủy",
"canceling": "Đang hủy...",
"clientAutoStartDesc": "Tự động khởi động khi mở ứng dụng máy khách và duy trì kết nối.",
"clientManualStartDesc": "Sử dụng nút Bắt đầu và Dừng trên dòng này. Termix sẽ không tự động mở.",
"clientRemoteServerNote": "Chuyển tiếp từ xa có thể yêu cầu AllowTcpForwarding và GatewayPorts trên máy chủ SSH điểm cuối. Cổng từ xa sẽ đóng khi máy này ngắt kết nối.",
"clientTunnelStarted": "Đường hầm máy khách đã khởi động",
"clientTunnelStopped": "Đường hầm máy khách đã dừng",
"clientTunnels": "Đường hầm máy khách",
"connected": "Đã kết nối",
"connecting": "Đang kết nối...",
"createFirstTunnelMessage": "Bạn chưa tạo đường hầm SSH nào. Hãy cấu hình kết nối đường hầm trong Trình quản lý máy chủ để bắt đầu.",
"currentHostPort": "Cổng máy hiện tại",
"destination": "Điểm đến",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "Đã ngắt kết nối",
"duplicateAutoStartBind": "Chỉ một đường hầm máy khách tự động có thể sử dụng {{bind}}.",
"endpointPort": "Cổng điểm cuối",
"endpointSshHost": "Máy chủ SSH điểm cuối",
"endpointSshHostPlaceholder": "Chọn máy chủ đã cấu hình",
"endpointSshHostRequired": "Chọn máy chủ SSH điểm cuối cho mỗi đường hầm máy khách.",
"error": "Lỗi",
"forwardDescriptionClientDynamic": "SOCKS trên cổng cục bộ {{sourcePort}}.",
"forwardDescriptionClientLocal": "Cục bộ {{sourcePort}} → từ xa {{endpointPort}}.",
"forwardDescriptionClientRemote": "Từ xa {{sourcePort}} → cục bộ {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS trên máy chủ hiện tại {{sourcePort}}.",
"forwardDescriptionServerLocal": "Máy chủ hiện tại {{sourcePort}} → điểm cuối {{endpointPort}}.",
"forwardDescriptionServerRemote": "Điểm cuối {{endpointPort}} → máy chủ hiện tại {{sourcePort}}.",
"host": "Máy chủ",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "IP cục bộ phải là địa chỉ IPv4 hợp lệ.",
"invalidEndpointPort": "Cổng điểm cuối phải nằm trong khoảng 1 đến 65535.",
"invalidLocalPort": "Cổng cục bộ phải nằm trong khoảng 1 đến 65535.",
"invalidLocalTargetIp": "IP đích cục bộ phải là địa chỉ IPv4 hợp lệ.",
"invalidLocalTargetPort": "Cổng đích cục bộ phải nằm trong khoảng 1 đến 65535.",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "Cổng từ xa phải nằm trong khoảng 1 đến 65535.",
"lastError": "Lỗi cuối cùng",
"lastStarted": "Lần bắt đầu cuối",
"lastTested": "Lần kiểm tra cuối",
"localAddress": "Local Address",
"localPort": "Cổng cục bộ",
"localSaveError": "Lưu đường hầm máy khách cục bộ thất bại",
"localSaved": "Đã lưu đường hầm máy khách",
"manualControlError": "Cập nhật trạng thái đường hầm thất bại.",
"maxRetries": "Số lần thử lại tối đa",
"mode": "Chế độ",
"nextRetryIn": "Thử lại sau {{seconds}} giây",
"noClientTunnels": "Chưa có đường hầm máy khách nào được cấu hình trên máy này.",
"noHostSelected": "Chưa chọn máy chủ",
"noSshTunnels": "Không có đường hầm SSH",
"port": "Cổng",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "Máy chủ từ xa",
"remotePort": "Cổng từ xa",
"retryInterval": "Khoảng thời gian thử lại (giây)",
"route": "Tuyến:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS qua {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Kiểm tra",
"tunnelName": "Tên đường hầm",
"tunnelTestFailed": "Kiểm tra đường hầm thất bại",
"tunnelTestSucceeded": "Kiểm tra đường hầm thành công",
"type": "Loại đường hầm",
"typeDynamic": "Động (-D)",
"typeLocal": "Cục bộ (-L)",
"typeRemote": "Từ xa (-R)",
"working": "Đang làm việc...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "Đường hầm hoạt động",
"start": "Bắt đầu",
"stop": "Dừng",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "Tiếp tục"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "Đường hầm",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "添加隧道",
"autoStartDesc": "主机加载时自动连接此隧道",
"autoStartLabel": "自动启动",
"connectBtn": "连接",
"disconnectBtn": "断开",
"enableTunneling": "启用隧道",
"enableTunnelingDesc": "为此主机启用 SSH 隧道功能",
"endpointHost": "端点主机",
"endpointHostPlaceholder": "相同主机、SSH 主机或可访问的主机/IP",
"endpointPort": "端点端口",
"failedToConnectTunnel": "连接失败",
"failedToDisconnectTunnel": "断开连接失败",
"maxRetries": "最大重试次数",
"noTunnelsConfigured": "未配置隧道。",
"retryIntervalS": "重试间隔(秒)",
"serverTunnelsSection": "服务器隧道",
"sourcePort": "源端口",
"tabTunnels": "隧道",
"tunnelConnecting": "隧道连接中...",
"tunnelDisconnected": "隧道已断开连接",
"tunnelLabel": "隧道 {{number}}",
"tunnelModeDynamicDesc": "在本地端口上创建 SOCKS5 代理,用于动态端口转发。",
"tunnelModeLocalDesc": "将本地端口转发到远程服务器(或可从其访问的主机)上的端口。",
"tunnelModeRemoteDesc": "将远程服务器上的端口转发回您机器上的本地端口。",
"tunnelRequirementsText": "SSH 服务器必须在 /etc/ssh/sshd_config 中设置 GatewayPorts yes、AllowTcpForwarding yes 和 PermitRootLogin yes。",
"tunnelSettings": "隧道设置",
"tunnelType": "隧道类型",
"bindHost": "绑定主机",
"docsLink": "查看文档",
"hostNotFound": "未找到主机",
"loadingHost": "正在加载主机..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "隧道"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (本地监听)",
"localTargetHost": "127.0.0.1 (此计算机上的目标)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1(SOCKS 监听器)"
},
"profile": {
"c2sCurrentLocalConfig": "此桌面上已配置 {{count}} 条本地客户端隧道。",
"c2sLoadPreset": "加载",
"c2sNoPresetSelected": "未选择预设",
"c2sNoPresets": "无保存的预设",
"c2sPresetDeleted": "客户端隧道预设已删除",
"c2sPresetLoadError": "加载客户端隧道预设失败",
"c2sPresetLoaded": "客户端隧道预设已在本地加载",
"c2sPresetName": "预设名称",
"c2sPresetNamePlaceholder": "客户端预设名称",
"c2sPresetRenamed": "客户端隧道预设已重命名",
"c2sPresetSaved": "客户端隧道预设已保存",
"c2sPresetSyncNote": "预设是显式快照;加载预设将替换此桌面客户端的本地客户端隧道列表。",
"c2sPresetToLoad": "要加载的预设",
"c2sTunnelPresets": "客户端隧道预设",
"c2sTunnelPresetsUnavailable": "客户端隧道预设仅在桌面客户端中可用。",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "尝试 {{current}}/{{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} 通过 {{endpoint}}",
"autoNameClientLocal": "本地 {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → 本地 {{localPort}}",
"autoStart": "自动启动",
"cancel": "取消",
"canceling": "取消中...",
"clientAutoStartDesc": "在此桌面客户端打开时启动并保持连接。",
"clientManualStartDesc": "从此行使用启动和停止。Termix 不会自动启动它。",
"clientRemoteServerNote": "远程转发可能需要在端点 SSH 服务器上设置 AllowTcpForwarding 和 GatewayPorts。此桌面断开连接时远程端口将关闭。",
"clientTunnelStarted": "客户端隧道已启动",
"clientTunnelStopped": "客户端隧道已停止",
"clientTunnels": "客户端隧道",
"connected": "已连接",
"connecting": "连接中...",
"createFirstTunnelMessage": "您尚未创建任何 SSH 隧道。在主机管理器中配置隧道连接以开始使用。",
"currentHostPort": "当前主机端口",
"destination": "目标",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "已断开",
"duplicateAutoStartBind": "只有一个自启动客户端隧道可以使用 {{bind}}。",
"endpointPort": "端点端口",
"endpointSshHost": "端点 SSH 主机",
"endpointSshHostPlaceholder": "选择已配置的主机",
"endpointSshHostRequired": "为每个客户端隧道选择一个端点 SSH 主机。",
"error": "错误",
"forwardDescriptionClientDynamic": "本地端口 {{sourcePort}} 上的 SOCKS。",
"forwardDescriptionClientLocal": "本地 {{sourcePort}} → 远程 {{endpointPort}}。",
"forwardDescriptionClientRemote": "远程 {{sourcePort}} → 本地 {{endpointPort}}。",
"forwardDescriptionServerDynamic": "当前主机上的 SOCKS {{sourcePort}}。",
"forwardDescriptionServerLocal": "当前主机 {{sourcePort}} → 端点 {{endpointPort}}。",
"forwardDescriptionServerRemote": "端点 {{endpointPort}} → 当前主机 {{sourcePort}}。",
"host": "主机",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "本地 IP 必须是有效的 IPv4 地址。",
"invalidEndpointPort": "端点端口必须在 1 到 65535 之间。",
"invalidLocalPort": "本地端口必须在 1 到 65535 之间。",
"invalidLocalTargetIp": "本地目标 IP 必须是有效的 IPv4 地址。",
"invalidLocalTargetPort": "本地目标端口必须在 1 到 65535 之间。",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "远程端口必须在 1 到 65535 之间。",
"lastError": "最后错误",
"lastStarted": "最后启动",
"lastTested": "最后测试",
"localAddress": "Local Address",
"localPort": "本地端口",
"localSaveError": "保存本地客户端隧道失败",
"localSaved": "客户端隧道已保存",
"manualControlError": "更新隧道状态失败。",
"maxRetries": "最大重试次数",
"mode": "模式",
"nextRetryIn": "{{seconds}} 秒后重试",
"noClientTunnels": "此桌面未配置客户端隧道。",
"noHostSelected": "未选择主机",
"noSshTunnels": "无 SSH 隧道",
"port": "端口",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "远程主机",
"remotePort": "远程端口",
"retryInterval": "重试间隔(秒)",
"route": "路由:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS 通过 {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "测试",
"tunnelName": "隧道名称",
"tunnelTestFailed": "隧道测试失败",
"tunnelTestSucceeded": "隧道测试成功",
"type": "隧道类型",
"typeDynamic": "动态 (-D)",
"typeLocal": "本地 (-L)",
"typeRemote": "远程 (-R)",
"working": "处理中...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "等待",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "活跃隧道",
"start": "启动",
"stop": "停止",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "继续"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "隧道",
"feature_tunnels_desc": "通过服务器转发端口以访问未暴露的服务。"
}
}
@@ -0,0 +1,175 @@
{
"hosts": {
"addTunnelBtn": "新增通道",
"autoStartDesc": "載入主機時自動連線此通道",
"autoStartLabel": "自動啟動",
"connectBtn": "連線",
"disconnectBtn": "中斷連線",
"enableTunneling": "啟用通道",
"enableTunnelingDesc": "為此主機啟用 SSH 通道功能",
"endpointHost": "端點主機",
"endpointHostPlaceholder": "同一主機、SSH 主機或可連線的主機/IP",
"endpointPort": "端點連接埠",
"failedToConnectTunnel": "連線失敗",
"failedToDisconnectTunnel": "中斷連線失敗",
"maxRetries": "最大重試次數",
"noTunnelsConfigured": "未設定任何通道。",
"retryIntervalS": "重試間隔(秒)",
"serverTunnelsSection": "伺服器通道",
"sourcePort": "來源連接埠",
"tabTunnels": "通道",
"tunnelConnecting": "通道連線中…",
"tunnelDisconnected": "通道已中斷連線",
"tunnelLabel": "通道 {{number}}",
"tunnelModeDynamicDesc": "在本機連接埠上建立 SOCKS5 代理以進行動態連接埠轉送。",
"tunnelModeLocalDesc": "將本機連接埠轉送至遠端伺服器的連接埠(或可從其連線的主機)。",
"tunnelModeRemoteDesc": "將遠端伺服器的連接埠轉送回本機的連接埠。",
"tunnelRequirementsText": "SSH 伺服器必須在 /etc/ssh/sshd_config 中設定 GatewayPorts yes、AllowTcpForwarding yes 以及 PermitRootLogin yes。",
"tunnelSettings": "通道設定",
"tunnelType": "通道類型",
"bindHost": "綁定主機",
"docsLink": "檢視文件",
"hostNotFound": "找不到主機",
"loadingHost": "正在載入主機..."
},
"nav": {
"portForwarding": "Forwarding",
"tunnels": "隧道"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (本機監聽)",
"localTargetHost": "127.0.0.1 (此電腦上的目標)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (listen on endpoint)",
"remoteTargetHost": "127.0.0.1 (target from endpoint)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1(SOCKS 監聽器)"
},
"profile": {
"c2sCurrentLocalConfig": "此桌面上設定了 {{count}} 個本機用戶端通道。",
"c2sLoadPreset": "載入",
"c2sNoPresetSelected": "未選取預設組",
"c2sNoPresets": "無儲存的預設組",
"c2sPresetDeleted": "用戶端通道預設組已刪除",
"c2sPresetLoadError": "無法載入用戶端通道預設組",
"c2sPresetLoaded": "用戶端通道預設組已在本機載入",
"c2sPresetName": "預設組名稱",
"c2sPresetNamePlaceholder": "用戶端預設組名稱",
"c2sPresetRenamed": "用戶端通道預設組已重新命名",
"c2sPresetSaved": "用戶端通道預設組已儲存",
"c2sPresetSyncNote": "預設組是明確的快照;載入預設組將會取代此桌面用戶端的本機用戶端通道清單。",
"c2sPresetToLoad": "要載入的預設組",
"c2sTunnelPresets": "用戶端通道預設組",
"c2sTunnelPresetsUnavailable": "用戶端通道預設組僅在桌面用戶端中可用。",
"c2sPresetRenameError": "Failed to rename the preset",
"c2sPresetDeleteError": "Failed to delete the preset"
},
"tunnels": {
"attempt": "嘗試 {{current}}/{{max}}",
"autoNameClientDynamic": "SOCKS {{localPort}} 透過 {{endpoint}}",
"autoNameClientLocal": "本機 {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → 本機 {{localPort}}",
"autoStart": "自動啟動",
"cancel": "取消",
"canceling": "正在取消…",
"clientAutoStartDesc": "在此桌面用戶端開啟時啟動並保持連線。",
"clientManualStartDesc": "從此列使用「啟動」與「停止」。Termix 不會自動開啟。",
"clientRemoteServerNote": "遠端轉送可能需要在端點 SSH 伺服器上啟用 AllowTcpForwarding 和 GatewayPorts。遠端連接埠會在此桌面中斷連線時關閉。",
"clientTunnelStarted": "用戶端通道已啟動",
"clientTunnelStopped": "用戶端通道已停止",
"clientTunnels": "用戶端通道",
"connected": "已連線",
"connecting": "連線中…",
"createFirstTunnelMessage": "您尚未建立任何 SSH 通道。請在主機管理員中設定通道連線以開始使用。",
"currentHostPort": "目前主機連接埠",
"destination": "目的地",
"destinationAddress": "Destination Address",
"destinationPort": "Destination Port",
"disconnected": "已離線",
"duplicateAutoStartBind": "只有一個自動啟動的用戶端通道可以使用 {{bind}}。",
"endpointPort": "端點連接埠",
"endpointSshHost": "端點 SSH 主機",
"endpointSshHostPlaceholder": "選擇已設定的主機",
"endpointSshHostRequired": "為每個用戶端通道選取一個端點 SSH 主機。",
"error": "錯誤",
"forwardDescriptionClientDynamic": "本機連接埠 {{sourcePort}} 上的 SOCKS。",
"forwardDescriptionClientLocal": "本機 {{sourcePort}} → 遠端 {{endpointPort}}。",
"forwardDescriptionClientRemote": "遠端 {{sourcePort}} → 本機 {{endpointPort}}。",
"forwardDescriptionServerDynamic": "目前主機上的 SOCKS {{sourcePort}}。",
"forwardDescriptionServerLocal": "目前主機 {{sourcePort}} → 端點 {{endpointPort}}。",
"forwardDescriptionServerRemote": "端點 {{endpointPort}} → 目前主機 {{sourcePort}}。",
"host": "主機",
"intermediateHost": "Intermediate Host",
"invalidBindIp": "本機 IP 必須是有效的 IPv4 位址。",
"invalidEndpointPort": "端點連接埠必須介於 1 到 65535 之間。",
"invalidLocalPort": "本機連接埠必須介於 1 到 65535 之間。",
"invalidLocalTargetIp": "本機目標 IP 必須是有效的 IPv4 位址。",
"invalidLocalTargetPort": "本機目標連接埠必須介於 1 到 65535 之間。",
"invalidRemoteAddress": "Remote address is required.",
"invalidRemotePort": "遠端連接埠必須介於 1 到 65535 之間。",
"lastError": "上次錯誤",
"lastStarted": "上次啟動",
"lastTested": "上次測試",
"localAddress": "Local Address",
"localPort": "本機連接埠",
"localSaveError": "無法儲存本機用戶端通道",
"localSaved": "用戶端通道已儲存",
"manualControlError": "無法更新通道狀態。",
"maxRetries": "最大重試次數",
"mode": "模式",
"nextRetryIn": "下次重試於 {{seconds}} 秒後",
"noClientTunnels": "此桌面未設定任何用戶端通道。",
"noHostSelected": "未選取主機",
"noSshTunnels": "無 SSH 通道",
"port": "連接埠",
"remoteBindAddress": "Remote Bind Address",
"remoteHost": "遠端主機",
"remotePort": "遠端連接埠",
"retryInterval": "重試間隔(秒)",
"route": "路由:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS 透過 {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "測試",
"tunnelName": "通道名稱",
"tunnelTestFailed": "通道測試失敗",
"tunnelTestSucceeded": "通道測試成功",
"type": "通道類型",
"typeDynamic": "動態 (-D)",
"typeLocal": "本機 (-L)",
"typeRemote": "遠端 (-R)",
"working": "處理中...",
"activeCount": "{{connected}}/{{total}} active",
"listensOn": "Bound to localhost:{{port}}",
"socksProxy": "SOCKS5 proxy",
"waiting": "Waiting",
"remoteSessionExpired": "Remote Sync session expired. Reconnect Remote Sync and try again.",
"activeTunnels": "活動通道",
"start": "啟動",
"stop": "停止",
"authTitle": "Tunnel authentication",
"authRetry": "The previous code was not accepted. Enter a new code.",
"authAnswer": "Authentication response",
"authContinue": "繼續"
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels",
"description": "Set up SSH tunnels through this host."
}
}
},
"onboarding": {
"feature_tunnels": "通道",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed."
}
}
+97
View File
@@ -0,0 +1,97 @@
{
"id": "tunnels",
"name": "Tunnels",
"version": "1.0.0",
"description": "SSH port forwarding between servers, plus local tunnels from the desktop app.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Networking",
"icon": "ArrowLeftRight",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface",
"users:impersonate",
"db:core-refs"
],
"provides": [
{
"service": "tunnels.access",
"version": "1.0.0",
"permission": "tunnels.use"
}
],
"contributes": {
"tabs": [
{
"id": "tunnel",
"titleKey": "nav.tunnels",
"icon": "ArrowLeftRight",
"openFrom": ["host-context-menu"]
}
],
"panels": [
{
"id": "port-forwarding",
"titleKey": "nav.portForwarding",
"icon": "Network"
}
],
"actions": [
{
"id": "tunnels.statuses",
"titleKey": "nav.tunnels",
"handler": "statuses",
"permission": "tunnels.use"
},
{
"id": "tunnels.open",
"titleKey": "nav.tunnels",
"handler": "open",
"permission": "tunnels.use"
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"host": {
"enableKey": "enableTunnel",
"enableLabelKey": "settings.host.enable.label",
"enableDescriptionKey": "settings.host.enable.description",
"fields": [
{
"key": "tunnelConnections",
"type": "custom",
"component": "tunnelConnections",
"requires": "enableTunnel",
"defaultable": false
}
]
}
},
"hostCapability": {
"key": "enableTunnel",
"labelKey": "hosts.tabTunnels",
"editorTab": "general"
},
"uses": ["homepage.widgets"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,15 @@
-- tunnels 0001: adopt_c2s_tunnel_presets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `c2s_tunnel_presets` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`config` text NOT NULL,
`platform` text,
`computer_name` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `c2s_tunnel_presets` TO `p_tunnels_presets`;
@@ -0,0 +1,15 @@
-- tunnels 0001: adopt_c2s_tunnel_presets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "c2s_tunnel_presets" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"config" text NOT NULL,
"platform" text,
"computer_name" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "c2s_tunnel_presets" RENAME TO "p_tunnels_presets";
+45
View File
@@ -0,0 +1,45 @@
{
"version": 1,
"tables": {
"presets": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"config": {
"type": "text",
"notNull": true
},
"platform": {
"type": "text"
},
"computerName": {
"type": "text"
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "c2s_tunnel_presets"
}
}
}
@@ -0,0 +1,15 @@
-- tunnels 0001: adopt_c2s_tunnel_presets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "c2s_tunnel_presets" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"config" text NOT NULL,
"platform" text,
"computer_name" text,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "c2s_tunnel_presets" RENAME TO "p_tunnels_presets";
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@termix-plugin/tunnels",
"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/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"react": "^19.2.8",
"sonner": "^2.0.8",
"ssh2": "^1.17.0",
"ws": "^8.21.3"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { isReservedTunnelName, parseReservedTunnelName } from "./utils.js";
/**
* Ownership check for an action addressed by tunnel name alone.
*
* On-demand (forward) tunnels are deliberately never registered as configs,
* so a check that only ran for a registered config would never run for them.
* Host ids are small sequential integers and endpoint ids come from the
* client, so anyone could guess `web:{hostId}:{endpointId}` and force-close
* another user's live tunnel. The host id is read back out of the reserved
* name instead, and a reserved name whose host id cannot be parsed fails
* closed rather than falling through to the unchecked path.
*/
export async function authorizeTunnelAction(
canAccessHost: (hostId: number) => Promise<boolean>,
tunnelName: string,
config: { sourceHostId?: number } | undefined,
): Promise<{ allowed: boolean; hostId?: number }> {
const hostId =
config?.sourceHostId ?? parseReservedTunnelName(tunnelName)?.hostId;
if (hostId === undefined) {
// Not reserved and not registered: nothing is running under this name
// that anyone could own, so there is nothing to protect.
return { allowed: !isReservedTunnelName(tunnelName) };
}
return { allowed: await canAccessHost(hostId), hostId };
}
+94
View File
@@ -0,0 +1,94 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TunnelManager } from "./manager.js";
import { errorMessage } from "./manager.js";
import {
buildTunnelConfig,
connectionToRequest,
readTunnelConnections,
resolveEndpoint,
} from "./config.js";
export interface HostOwner {
id: number;
userId: string;
}
/** Every host and its owner, straight from ssh_data. */
export async function listHostOwners(ctx: PluginContext): Promise<HostOwner[]> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const { hosts } = await ctx.db.refs<{ hosts: any }>();
const drizzle = await ctx.db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const rows = (await drizzle
.select({ id: hosts.id, userId: hosts.userId })
.from(hosts)) as HostOwner[];
return rows.filter((row) => Number.isInteger(row.id) && !!row.userId);
}
/**
* Starts every saved tunnel marked autoStart, across every user.
*
* ctx.hosts only sees the acting user's hosts, so the owners come from
* ssh_data first and each host is then resolved and connected as its owner.
*/
export async function startAutoStartTunnels(
ctx: PluginContext,
manager: TunnelManager,
listOwners: () => Promise<HostOwner[]> = () => listHostOwners(ctx),
): Promise<number> {
let started = 0;
let owners: HostOwner[];
try {
owners = await listOwners();
} catch (error) {
ctx.log.warn(
`Could not list hosts for tunnel autostart: ${errorMessage(error)}`,
);
return 0;
}
for (const owner of owners) {
try {
if (!(await ctx.settings.getHost<boolean>(owner.id, "enableTunnel"))) {
continue;
}
const connections = readTunnelConnections(
await ctx.settings.getHost(owner.id, "tunnelConnections"),
);
if (!connections.some((connection) => connection.autoStart)) continue;
started += await ctx.asUser(owner.userId, async () => {
const host = await ctx.hosts.get(owner.id);
if (!host) return 0;
let count = 0;
for (const [index, connection] of connections.entries()) {
if (!connection.autoStart) continue;
try {
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(
host,
connectionToRequest(host, index, connection),
owner.userId,
),
);
await manager.start(config);
count++;
} catch (error) {
ctx.log.warn(
`Autostart tunnel ${index + 1} on host ${owner.id} skipped: ${errorMessage(error)}`,
);
}
}
return count;
});
} catch (error) {
ctx.log.warn(
`Autostart failed for host ${owner.id}: ${errorMessage(error)}`,
);
}
}
if (started > 0) ctx.log.info(`Started ${started} autostart tunnel(s)`);
return started;
}
+53
View File
@@ -0,0 +1,53 @@
import { randomUUID } from "node:crypto";
import type { WebSocket } from "ws";
import type { PluginSshPromptChannel } from "@termix/plugin-sdk/backend";
/** Answers belong to this authenticated socket and one outstanding challenge. */
export function createC2SPrompt(ws: WebSocket): PluginSshPromptChannel {
return {
ask(request) {
if (ws.readyState !== 1)
return Promise.reject(new Error("Tunnel closed"));
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const finish = (answer: string | null, error?: Error) => {
clearTimeout(timer);
ws.off("message", onMessage);
ws.off("close", onClose);
ws.off("error", onClose);
if (error) reject(error);
else resolve(answer);
};
const onClose = () =>
finish(null, new Error("Tunnel authentication cancelled"));
const onMessage = (raw: Buffer, binary: boolean) => {
if (binary) return;
let value;
try {
value = JSON.parse(raw.toString());
} catch {
return;
}
if (value.type !== "auth-response" || value.requestId !== requestId)
return;
if (value.answer === null) {
onClose();
ws.close();
} else if (
typeof value.answer === "string" &&
value.answer.length <= 8192
)
finish(value.answer);
};
const timer = setTimeout(() => {
finish(null, new Error("Tunnel authentication timed out"));
ws.close();
}, 60_000);
ws.on("message", onMessage);
ws.once("close", onClose);
ws.once("error", onClose);
ws.send(JSON.stringify({ type: "auth-prompt", requestId, request }));
});
},
};
}
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import type { Client } from "ssh2";
import type { WebSocket } from "ws";
import type {
PluginContext,
PluginSshConnection,
} from "@termix/plugin-sdk/backend";
import { createC2SPrompt } from "./c2s-auth.js";
/** A desktop runtime holds one authenticated SSH connection across SOCKS streams. */
export function createC2SConnections(ctx: PluginContext) {
type Entry = {
sockets: Set<WebSocket>;
source: Promise<PluginSshConnection<Client>>;
};
const entries = new Map<string, Entry>();
ctx.disposables.add(() => {
for (const entry of entries.values())
for (const ws of entry.sockets) ws.close();
entries.clear();
});
return async (ws: WebSocket, hostId: number, sessionId?: string) => {
if (ws.readyState !== 1) throw new Error("Tunnel closed");
const key = JSON.stringify([
ctx.currentActor(),
hostId,
typeof sessionId === "string" && sessionId.length <= 128
? sessionId
: randomUUID(),
]);
let entry = entries.get(key);
if (!entry) {
const source = ctx.ssh.connect<Client>(hostId, {
purpose: "tunnel",
profile: "forward",
timeoutMs: 60_000,
prompt: createC2SPrompt(ws),
});
entry = { sockets: new Set(), source };
entries.set(key, entry);
const own = entry;
void source.then(
(connection) => {
const close = () => {
if (entries.get(key) === own) entries.delete(key);
for (const socket of own.sockets) socket.close();
};
connection.client.once("close", close);
connection.client.once("error", close);
},
() => {
if (entries.get(key) === own) entries.delete(key);
for (const socket of own.sockets) {
if (socket.readyState === 1)
socket.send(
JSON.stringify({
type: "error",
error: "Endpoint SSH authentication failed",
}),
);
socket.close();
}
},
);
}
const own = entry;
own.sockets.add(ws);
let released = false;
const dispose = () => {
if (released) return;
released = true;
ws.off("close", dispose);
ws.off("error", dispose);
own.sockets.delete(ws);
if (!own.sockets.size) {
if (entries.get(key) === own) entries.delete(key);
void own.source.then(
(source) => source.dispose(),
() => {},
);
}
};
ws.once("close", dispose);
ws.once("error", dispose);
try {
const source = await own.source;
if (released || ws.readyState !== 1) throw new Error("Tunnel closed");
return { ...source, dispose };
} catch (error) {
dispose();
throw error;
}
};
}
+456
View File
@@ -0,0 +1,456 @@
import type { Duplex } from "node:stream";
import type { ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createC2SConnections } from "./c2s-connections.js";
import type { TunnelMode } from "./types.js";
import {
bindForwardIn,
forwardOut,
unbindForwardIn,
} from "./ssh-primitives.js";
import { getTunnelMode } from "./utils.js";
/** What the desktop app sends in the first message on the relay socket. */
export interface C2SRelayTunnel {
name?: string;
sessionId?: string;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
targetHost?: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourcePort?: number;
endpointPort?: number;
}
export interface C2SOpenMessage {
type: "open" | "test";
keepAlive?: boolean;
tunnelConfig?: C2SRelayTunnel;
targetHost?: string;
targetPort?: number;
}
const WS_HIGH_WATERMARK = 1024 * 1024;
const WS_LOW_WATERMARK = 256 * 1024;
const STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
export function sendC2SError(ws: WebSocket, message: string): void {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: "error", error: message }));
}
}
export function describeC2SRelayError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
if (
lower.includes("administratively prohibited") ||
lower.includes("forwarding disabled") ||
lower.includes("open failed")
) {
return `SSH forwarding was rejected by the endpoint server: ${message}`;
}
if (
lower.includes("address already in use") ||
lower.includes("unable to bind") ||
lower.includes("bind")
) {
return `Remote port is not available on the endpoint server: ${message}`;
}
if (
lower.includes("name or service not known") ||
lower.includes("enotfound") ||
lower.includes("econnrefused")
) {
return `Tunnel target is not reachable from the endpoint host: ${message}`;
}
return message || "Failed to open relay";
}
function pauseForBackpressure(ws: WebSocket, source?: Duplex): void {
if (!source || ws.bufferedAmount <= WS_HIGH_WATERMARK) return;
source.pause();
const resumeTimer = setInterval(() => {
if (
ws.readyState !== 1 ||
source.destroyed ||
ws.bufferedAmount <= WS_LOW_WATERMARK
) {
clearInterval(resumeTimer);
if (ws.readyState === 1 && !source.destroyed) source.resume();
}
}, 25);
}
function sendMessage(
ws: WebSocket,
message: Record<string, unknown>,
source?: Duplex,
): void {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(message), (error) => {
if (error && source && !source.destroyed) source.destroy(error);
});
pauseForBackpressure(ws, source);
}
function writeRemoteChunk(
target: ClientChannel,
chunk: Buffer,
ws: WebSocket,
closeTarget: () => void,
): void {
if (!target || target.destroyed) return;
if (target.writableLength > STREAM_WRITE_LIMIT) {
closeTarget();
return;
}
if (!target.write(chunk)) {
ws.pause();
target.once("drain", () => {
if (ws.readyState === 1) ws.resume();
});
}
}
/**
* The host a client tunnel runs through. A desktop app synced with this
* server knows the host by its sync id, since numeric ids differ between the
* two databases, and must never fall back to a numeric id that could point
* at a different machine here.
*/
export async function resolveC2SSourceHostId(
tunnel: Pick<C2SRelayTunnel, "sourceHostId" | "sourceHostSyncId">,
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
): Promise<number> {
const syncId = tunnel.sourceHostSyncId?.trim();
if (syncId) {
const hostId = await findHostIdBySyncId(syncId);
if (!hostId) {
throw new Error("Endpoint SSH host was not found on the remote server");
}
return hostId;
}
if (!tunnel.sourceHostId) throw new Error("Endpoint SSH host is required");
return tunnel.sourceHostId;
}
function address(value: unknown, fallback: string): string {
const trimmed = typeof value === "string" ? value.trim() : "";
return trimmed || fallback;
}
interface ResolvedRelay {
name: string;
sessionId?: string;
hostId: number;
mode: TunnelMode;
remoteAddress: string;
sourcePort: number;
endpointPort: number;
}
/**
* The client tunnel relay: the desktop app listens locally and streams each
* connection over this socket, and the server forwards it through one of the
* user's hosts. Runs as the socket's user for every host lookup and connect.
*/
export function createC2SRelay(ctx: PluginContext) {
let streamCounter = 0;
const connectSource = createC2SConnections(ctx);
async function findHostIdBySyncId(syncId: string): Promise<number | null> {
/* eslint-disable @typescript-eslint/no-explicit-any */
const { hosts } = await ctx.db.refs<{ hosts: any }>();
const drizzle = await ctx.db.client<any>();
/* eslint-enable @typescript-eslint/no-explicit-any */
const { eq } = await import("drizzle-orm");
const rows = await drizzle
.select({ id: hosts.id })
.from(hosts)
.where(eq(hosts.syncId, syncId))
.limit(1);
return rows[0]?.id ?? null;
}
async function resolve(tunnel: C2SRelayTunnel): Promise<ResolvedRelay> {
const hostId = await resolveC2SSourceHostId(tunnel, findHostIdBySyncId);
const access = await ctx.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) throw new Error("Access denied to this host");
return {
name: tunnel.name || `c2s:${hostId}`,
hostId,
sessionId: tunnel.sessionId,
mode: getTunnelMode({
mode: tunnel.mode,
tunnelType: tunnel.tunnelType,
}),
remoteAddress: address(
tunnel.remoteAddress,
address(tunnel.targetHost, "127.0.0.1"),
),
sourcePort: Number(tunnel.sourcePort) || 0,
endpointPort: Number(tunnel.endpointPort) || 0,
};
}
async function openRemote(ws: WebSocket, relay: ResolvedRelay) {
const bindHost = relay.remoteAddress;
const bindPort = relay.sourcePort;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const source = await connectSource(ws, relay.hostId, relay.sessionId);
const sourceClient = source.client;
let actualPort: number;
try {
actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
} catch (error) {
source.dispose();
throw error;
}
if (ws.readyState !== 1) {
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
source.dispose();
return;
}
const streams = new Map<string, ClientChannel>();
let closed = false;
const closeStream = (streamId: string) => {
const stream = streams.get(streamId);
if (!stream) return;
streams.delete(streamId);
try {
stream.destroy();
} catch {
// Already gone.
}
};
const close = () => {
if (closed) return;
closed = true;
for (const streamId of [...streams.keys()]) closeStream(streamId);
unbindForwardIn(sourceClient, bindHost, actualPort, ctx.log);
source.dispose();
};
sourceClient.on("tcp connection", (info, accept, reject) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
const streamId = `${Date.now()}-${++streamCounter}`;
streams.set(streamId, inbound);
sendMessage(ws, { type: "connection", streamId });
inbound.on("data", (chunk: Buffer) => {
sendMessage(
ws,
{ type: "data", streamId, data: chunk.toString("base64") },
inbound,
);
});
inbound.on("close", () => {
streams.delete(streamId);
sendMessage(ws, { type: "close", streamId });
});
inbound.on("error", (error: Error) => {
streams.delete(streamId);
sendMessage(ws, { type: "close", streamId, error: error.message });
});
});
ws.on("message", (data, isBinary) => {
if (isBinary) return;
try {
const message = JSON.parse(data.toString()) as {
type?: string;
streamId?: string;
data?: string;
};
if (!message.streamId) return;
const streamId = message.streamId;
if (message.type === "data" && message.data) {
const stream = streams.get(streamId);
if (stream) {
writeRemoteChunk(
stream,
Buffer.from(message.data, "base64"),
ws,
() => closeStream(streamId),
);
}
} else if (message.type === "close") {
closeStream(streamId);
}
} catch (error) {
ctx.log.warn(
`Invalid client tunnel relay message for ${relay.name}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
});
ws.on("close", close);
ws.on("error", close);
sourceClient.on("close", () => {
if (ws.readyState === 1) ws.close();
});
sourceClient.on("error", (error: Error) => {
sendMessage(ws, { type: "error", error: error.message });
if (ws.readyState === 1) ws.close();
});
ctx.log.info(
`Client tunnel ${relay.name} bound ${bindHost}:${actualPort} on host ${relay.hostId}`,
);
sendMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
}
async function open(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
const relay = await resolve(message.tunnelConfig ?? {});
if (relay.mode === "remote") {
await openRemote(ws, relay);
return;
}
const targetHost =
relay.mode === "dynamic" ? message.targetHost : relay.remoteAddress;
const targetPort =
relay.mode === "dynamic"
? Number(message.targetPort)
: relay.endpointPort;
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
throw new Error("Invalid client tunnel target");
}
const source = await connectSource(ws, relay.hostId, relay.sessionId);
let outbound: ClientChannel;
try {
outbound = await forwardOut(source.client, targetHost, targetPort);
} catch (error) {
source.dispose();
throw error;
}
if (ws.readyState !== 1) {
outbound.destroy();
source.dispose();
return;
}
const close = () => {
try {
outbound.destroy();
} catch {
// Already gone.
}
source.dispose();
};
outbound.on("data", (chunk: Buffer) => {
if (ws.readyState === 1) ws.send(chunk);
});
outbound.on("close", () => {
if (ws.readyState === 1) ws.close();
});
outbound.on("error", () => {
if (ws.readyState === 1) ws.close();
});
ws.on("close", close);
ws.on("error", close);
ws.on("message", (data, isBinary) => {
if (!isBinary) return;
const chunk = Buffer.isBuffer(data)
? data
: Array.isArray(data)
? Buffer.concat(data)
: Buffer.from(data as ArrayBuffer);
outbound.write(chunk);
});
ws.send(JSON.stringify({ type: "ready" }));
}
async function test(ws: WebSocket, message: C2SOpenMessage): Promise<void> {
const relay = await resolve(message.tunnelConfig ?? {});
const source = await connectSource(ws, relay.hostId, relay.sessionId);
try {
if (relay.mode === "remote") {
const bindPort = relay.sourcePort;
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
throw new Error("Invalid remote port");
}
const actualPort = await bindForwardIn(
source.client,
relay.remoteAddress,
bindPort,
);
unbindForwardIn(source.client, relay.remoteAddress, actualPort);
} else if (relay.mode === "local") {
if (!Number.isInteger(relay.endpointPort) || relay.endpointPort < 1) {
throw new Error("Invalid remote target port");
}
const outbound = await forwardOut(
source.client,
relay.remoteAddress,
relay.endpointPort,
);
outbound.destroy();
}
sendMessage(ws, { type: "ready" });
} finally {
if (!message.keepAlive) source.dispose();
}
}
/** The ctx.ws.route handler. Core has already authenticated the socket. */
return function handleConnection(ws: WebSocket, userId: string): void {
let opened = false;
ws.on("error", (error: Error) => {
ctx.log.warn(`Client tunnel relay socket error: ${error.message}`);
});
ws.once("message", (raw) => {
void ctx
.asUser(userId, async () => {
if (!(await ctx.rbac.has("use"))) {
throw new Error("Access denied");
}
const message = JSON.parse(raw.toString()) as C2SOpenMessage;
if (message.type !== "open" && message.type !== "test") {
throw new Error("Invalid client tunnel relay request");
}
opened = true;
if (message.type === "test") await test(ws, message);
else await open(ws, message);
})
.catch((error: unknown) => {
const message = describeC2SRelayError(error);
ctx.log.warn(`Failed to open client tunnel relay: ${message}`);
sendC2SError(ws, message);
ws.close();
});
});
ws.on("close", () => {
if (!opened) ctx.log.debug("Client tunnel relay closed before opening");
});
};
}
+172
View File
@@ -0,0 +1,172 @@
import type {
PluginContext,
PluginHostSummary,
} from "@termix/plugin-sdk/backend";
import type {
TunnelConfig,
TunnelConnectRequest,
TunnelConnection,
} from "./types.js";
import { serverTunnelName } from "../shared/tunnel-naming.js";
import {
findHostByTunnelEndpoint,
getTunnelMode,
isSingleHostTunnel,
parseTunnelName,
} from "./utils.js";
export function hostLabel(host: {
name?: string | null;
username: string;
ip: string;
}): string {
return host.name || `${host.username}@${host.ip}`;
}
/** The name a host's saved tunnel runs under. */
export function savedTunnelName(
host: PluginHostSummary,
index: number,
connection: Pick<
TunnelConnection,
"sourcePort" | "endpointHost" | "endpointPort"
>,
): string {
return serverTunnelName(host, index, connection);
}
/**
* Builds the runtime config for a tunnel from what the client or a host's
* saved list says about it. Credentials are never part of it: both legs
* resolve theirs through ctx.ssh as `userId`.
*/
export function buildTunnelConfig(
host: PluginHostSummary,
request: Omit<TunnelConnectRequest, "sourceHostId">,
userId: string,
): TunnelConfig {
const mode = getTunnelMode(request);
return {
name: request.name,
scope: request.scope || "s2s",
mode,
tunnelType: request.tunnelType || (mode === "remote" ? "remote" : "local"),
bindHost: request.bindHost,
targetHost: request.targetHost,
sourceHostId: host.id,
tunnelIndex: request.tunnelIndex,
requestingUserId: userId,
hostName: hostLabel(host),
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
endpointHost: (request.endpointHost ?? "").trim(),
sourcePort: Number(request.sourcePort),
endpointPort: Number(request.endpointPort),
maxRetries: Number(request.maxRetries) || 3,
retryInterval: (Number(request.retryInterval) || 5) * 1000,
autoStart: Boolean(request.autoStart),
};
}
export function connectionToRequest(
host: PluginHostSummary,
index: number,
connection: TunnelConnection,
): Omit<TunnelConnectRequest, "sourceHostId"> {
return {
name: savedTunnelName(host, index, connection),
tunnelIndex: index,
scope: connection.scope || "s2s",
mode: connection.mode,
tunnelType: connection.tunnelType,
bindHost: connection.bindHost,
targetHost: connection.targetHost,
endpointHost: connection.endpointHost,
sourcePort: connection.sourcePort,
endpointPort: connection.endpointPort,
maxRetries: connection.maxRetries,
retryInterval: connection.retryInterval,
autoStart: connection.autoStart,
};
}
/**
* Fills in the endpoint leg from the user's own host list. A local or
* dynamic tunnel whose endpoint is not a saved host just forwards to that
* address through the source; a remote tunnel needs a real SSH host there.
* Must run as the tunnel's user.
*/
export async function resolveEndpoint(
ctx: PluginContext,
config: TunnelConfig,
): Promise<TunnelConfig> {
if (isSingleHostTunnel(config)) return config;
const hosts = await ctx.hosts.list();
const endpoint = findHostByTunnelEndpoint(hosts, config.endpointHost);
if (!endpoint) {
if (getTunnelMode(config) === "remote") {
throw new Error(
`Endpoint host '${config.endpointHost}' not found in database`,
);
}
return { ...config, endpointIP: config.endpointIP || config.endpointHost };
}
const access = await ctx.hosts.checkAccess(endpoint.id, "connect");
if (!access.hasAccess) throw new Error("Endpoint host not found");
return {
...config,
endpointHostId: endpoint.id,
endpointIP: endpoint.ip,
endpointSSHPort: endpoint.port,
endpointUsername: endpoint.username,
};
}
/** A host's saved tunnel list, as its host setting holds it. */
export function readTunnelConnections(value: unknown): TunnelConnection[] {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
}
/**
* The saved tunnel a name points at, for starting one by name alone
* (automations). Must run as the user it is for.
*/
export async function findSavedTunnel(
ctx: PluginContext,
name: string,
): Promise<{
host: PluginHostSummary;
index: number;
connection: TunnelConnection;
} | null> {
const parsed = parseTunnelName(name);
if (parsed.isLegacyFormat || parsed.hostId === undefined) return null;
const index = parsed.tunnelIndex ?? -1;
const host = await ctx.hosts.get(parsed.hostId);
if (!host) return null;
if (!(await ctx.settings.getHost<boolean>(host.id, "enableTunnel"))) {
return null;
}
const connections = readTunnelConnections(
await ctx.settings.getHost(host.id, "tunnelConnections"),
);
const connection = connections[index];
if (!connection) return null;
if (savedTunnelName(host, index, connection) !== name) return null;
return { host, index, connection };
}
+50
View File
@@ -0,0 +1,50 @@
import type { Router } from "express";
import type { WebSocket } from "ws";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { presets } from "./tables.js";
import { createPresetRepository } from "./repository.js";
import { createTunnelManager } from "./manager.js";
import { registerTunnelRoutes } from "./routes.js";
import { createC2SRelay } from "./c2s-relay.js";
import { createTunnelsService } from "./service.js";
import { startAutoStartTunnels } from "./autostart.js";
export type {
TunnelsAccess,
TunnelForwardHandle,
TunnelForwardTarget,
} from "./service.js";
// Long enough for core's host settings migration, which runs right after
// plugins activate, to have copied the saved tunnels across on an upgrade.
const AUTOSTART_DELAY_MS = 3000;
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(presets);
const repository = createPresetRepository(ctx.db, table);
const manager = createTunnelManager(ctx);
ctx.disposables.add(() => manager.dispose());
registerTunnelRoutes(ctx.http.router<Router>(), ctx, manager, repository);
const relay = createC2SRelay(ctx);
ctx.ws.route("/c2s/stream", (connection) =>
relay(connection.socket as WebSocket, connection.userId),
);
ctx.services.provide("tunnels.access", createTunnelsService(ctx, manager));
// Deferred so activation is not held up by SSH handshakes.
const autostart = setTimeout(() => {
void startAutoStartTunnels(ctx, manager);
}, AUTOSTART_DELAY_MS);
autostart.unref?.();
ctx.disposables.add(() => clearTimeout(autostart));
ctx.log.info("Tunnel routes mounted at /plugin-api/tunnels");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+839
View File
@@ -0,0 +1,839 @@
import { createServer, Socket, type Server } from "node:net";
import type { Client, ClientChannel } from "ssh2";
import type {
PluginContext,
PluginSshConnection,
} from "@termix/plugin-sdk/backend";
import {
CONNECTION_STATES,
type TunnelConfig,
type TunnelStatus,
} from "./types.js";
import {
bindForwardIn,
forwardOut,
pipeTunnelStreams,
unbindForwardIn,
} from "./ssh-primitives.js";
import { handleSocks5Connect } from "./socks5-relay.js";
import {
classifyTunnelError,
getTunnelBindHost,
getTunnelMode,
getTunnelScope,
isReservedTunnelName,
resolveS2SLocalTargetHost,
shouldEstablishDirectTunnel,
} from "./utils.js";
export interface TunnelRuntime {
sourceClient: Client;
endpointClient?: Client;
bindHost: string;
bindPort: number;
close: () => void;
}
/** A status snapshot as a user sees it, keyed by tunnel name. */
export type TunnelStatusMap = Record<string, TunnelStatus>;
const CONNECT_TIMEOUT_MS = 60_000;
const MANUAL_DISCONNECT_HOLD_MS = 5_000;
const ACCESS_CACHE_MS = 60_000;
export function errorMessage(
error: unknown,
fallback = "Unknown error",
): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
export type TunnelManager = ReturnType<typeof createTunnelManager>;
/**
* Every running tunnel and its retry state, created fresh in activate so a
* disable-then-enable starts clean.
*
* Both SSH legs go through ctx.ssh.connect, so tunnels get host key checks,
* auth providers, proxies and jump hosts the same way every other transport
* does. The endpoint leg of a source-to-endpoint tunnel is opened over a
* forwardOut channel on the source connection, handed to ctx.ssh as `sock`.
*/
export function createTunnelManager(ctx: PluginContext) {
const log = ctx.log;
const configs = new Map<string, TunnelConfig>();
const statuses = new Map<string, TunnelStatus>();
const runtimes = new Map<string, TunnelRuntime>();
const retryCounters = new Map<string, number>();
const retryTimers = new Map<string, NodeJS.Timeout>();
const countdownTimers = new Map<string, NodeJS.Timeout>();
const manualDisconnects = new Set<string>();
const manualHoldTimers = new Set<NodeJS.Timeout>();
const retryExhausted = new Set<string>();
const cleanupInProgress = new Set<string>();
const connecting = new Set<string>();
const lastErrors = new Map<string, string>();
const lastErrorTypes = new Map<string, TunnelStatus["errorType"]>();
const pendingOperations = new Map<string, Promise<void>>();
const listeners = new Set<() => void>();
const accessCache = new Map<string, { allowed: boolean; at: number }>();
let disposed = false;
function notify(): void {
for (const listener of listeners) {
try {
listener();
} catch (error) {
log.warn(`Tunnel status listener failed: ${errorMessage(error)}`);
}
}
}
function broadcast(name: string, status: TunnelStatus): void {
if (
status.status === CONNECTION_STATES.CONNECTED &&
retryTimers.has(name)
) {
return;
}
const next = { ...status };
if (retryExhausted.has(name) && next.status === CONNECTION_STATES.FAILED) {
const previous = lastErrors.get(name);
next.reason = previous
? `Max retries exhausted: ${previous}`
: "Max retries exhausted";
}
if (next.status === CONNECTION_STATES.FAILED && next.reason) {
lastErrors.set(name, next.reason);
if (next.errorType) lastErrorTypes.set(name, next.errorType);
} else if (
(next.status === CONNECTION_STATES.CONNECTING ||
next.status === CONNECTION_STATES.RETRYING ||
next.status === CONNECTION_STATES.WAITING) &&
!next.reason
) {
next.reason = lastErrors.get(name);
next.errorType = lastErrorTypes.get(name);
} else if (
next.status === CONNECTION_STATES.CONNECTED ||
(next.status === CONNECTION_STATES.DISCONNECTED && next.manualDisconnect)
) {
lastErrors.delete(name);
lastErrorTypes.delete(name);
}
statuses.set(name, next);
ctx.events.emit("plugin.tunnels.status", {
name,
sourceHostId: configs.get(name)?.sourceHostId,
status: next,
});
notify();
}
function clearRetryTimers(name: string): void {
const retry = retryTimers.get(name);
if (retry) {
clearTimeout(retry);
retryTimers.delete(name);
}
const countdown = countdownTimers.get(name);
if (countdown) {
clearInterval(countdown);
countdownTimers.delete(name);
}
}
function resetRetryState(name: string): void {
retryCounters.delete(name);
retryExhausted.delete(name);
lastErrors.delete(name);
lastErrorTypes.delete(name);
cleanupInProgress.delete(name);
connecting.delete(name);
clearRetryTimers(name);
}
async function cleanup(name: string, force = false): Promise<void> {
if (cleanupInProgress.has(name)) return;
if (!force && connecting.has(name)) return;
cleanupInProgress.add(name);
const runtime = runtimes.get(name);
if (runtime) {
runtimes.delete(name);
try {
runtime.close();
} catch (error) {
log.error(
`Error while closing tunnel ${name}`,
error instanceof Error ? error : undefined,
);
}
}
cleanupInProgress.delete(name);
clearRetryTimers(name);
}
/** One SSH leg, as the tunnel's owner, through core's connect pipeline. */
async function openLeg(
userId: string,
hostId: number,
sock?: ClientChannel,
): Promise<PluginSshConnection<Client>> {
return ctx.asUser(userId, () =>
ctx.ssh.connect<Client>(hostId, {
purpose: "tunnel",
profile: "forward",
timeoutMs: CONNECT_TIMEOUT_MS,
sock,
}),
);
}
function directTargetHost(config: TunnelConfig): string {
const target = config.targetHost?.trim();
if (target) return target;
if (
config.endpointHostId !== undefined &&
config.endpointHostId === config.sourceHostId
) {
return "127.0.0.1";
}
return config.endpointHost || "127.0.0.1";
}
async function establishDirect(
source: PluginSshConnection<Client>,
config: TunnelConfig,
): Promise<TunnelRuntime> {
const sourceClient = source.client;
const name = config.name;
const mode = getTunnelMode(config);
const bindHost = getTunnelBindHost(config);
const targetHost = directTargetHost(config);
const targetPort = config.endpointPort;
if (mode === "remote") {
const remoteBindPort = await bindForwardIn(
sourceClient,
targetHost,
config.sourcePort,
);
const sockets = new Set<Socket>();
const onTcp = (
info: { destPort: number },
accept: () => ClientChannel,
reject: () => void,
) => {
if (info.destPort !== remoteBindPort) {
reject();
return;
}
const inbound = accept();
const local = new Socket();
sockets.add(local);
local.connect(targetPort, bindHost, () => {
pipeTunnelStreams(inbound, Promise.resolve(local), name, log);
});
local.on("error", () => {
inbound.destroy();
sockets.delete(local);
});
local.on("close", () => sockets.delete(local));
};
sourceClient.on("tcp connection", onTcp);
return {
sourceClient,
bindHost: targetHost,
bindPort: remoteBindPort,
close: () => {
sourceClient.off("tcp connection", onTcp);
unbindForwardIn(sourceClient, targetHost, remoteBindPort, log);
for (const socket of sockets) socket.destroy();
sockets.clear();
source.dispose();
},
};
}
// Local and dynamic: listen here, forward through the source.
const sockets = new Set<Socket>();
const server: Server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("error", () => {
sockets.delete(socket);
socket.destroy();
});
if (mode === "dynamic") {
handleSocks5Connect(
socket,
(host, port) => forwardOut(sourceClient, host, port),
name,
log,
);
return;
}
forwardOut(sourceClient, targetHost, targetPort)
.then((outbound) =>
pipeTunnelStreams(socket, Promise.resolve(outbound), name, log),
)
.catch(() => socket.destroy());
});
const boundPort = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen({ host: bindHost, port: config.sourcePort }, () => {
server.removeListener("error", reject);
// With port 0 the kernel picks, so report what was actually bound.
resolve((server.address() as { port: number }).port);
});
});
let idleTimer: NodeJS.Timeout | undefined;
const close = () => {
if (idleTimer) clearInterval(idleTimer);
for (const socket of sockets) socket.destroy();
sockets.clear();
server.close();
source.dispose();
};
// On-demand tunnels close themselves once nothing has used them for a
// while. The socket set only exists in here, so the timer does too.
if (config.idleTimeoutMs && config.idleTimeoutMs > 0) {
const idleTimeoutMs = config.idleTimeoutMs;
let idleSince: number | null = Date.now();
idleTimer = setInterval(
() => {
if (sockets.size > 0) {
idleSince = null;
return;
}
if (idleSince === null) {
idleSince = Date.now();
return;
}
if (Date.now() - idleSince < idleTimeoutMs) return;
// Acts by name, so skip it if a reopen already replaced this one.
if (runtimes.get(name)?.sourceClient !== sourceClient) return;
log.info(`Closing idle tunnel ${name}`);
void cleanup(name, true);
},
Math.min(30_000, idleTimeoutMs),
);
idleTimer.unref();
}
log.info(
`Tunnel ${name} listening on ${bindHost}:${boundPort} (${mode}) to ${targetHost}:${targetPort}`,
);
return { sourceClient, bindHost, bindPort: boundPort, close };
}
async function establishManaged(
source: PluginSshConnection<Client>,
config: TunnelConfig,
): Promise<TunnelRuntime> {
const sourceClient = source.client;
const name = config.name;
if (config.endpointHostId === undefined || !config.endpointIP) {
throw new Error("Endpoint host not found");
}
const channel = await forwardOut(
sourceClient,
config.endpointIP,
config.endpointSSHPort ?? 22,
);
let endpoint: PluginSshConnection<Client>;
try {
endpoint = await openLeg(
config.requestingUserId,
config.endpointHostId,
channel,
);
} catch (error) {
channel.destroy();
throw error;
}
const endpointClient = endpoint.client;
const mode = getTunnelMode(config);
const bindHost = getTunnelBindHost(config);
const bindClient = mode === "remote" ? endpointClient : sourceClient;
const outboundClient = mode === "remote" ? sourceClient : endpointClient;
const bindPort =
mode === "remote" ? config.endpointPort : config.sourcePort;
const targetHost =
mode === "remote"
? config.targetHost || "127.0.0.1"
: resolveS2SLocalTargetHost(config);
const targetPort =
mode === "remote" ? config.sourcePort : config.endpointPort;
let actualPort: number;
try {
actualPort = await bindForwardIn(bindClient, bindHost, bindPort);
} catch (error) {
endpoint.dispose();
throw error;
}
const onTcp = (
info: { destPort: number },
accept: () => ClientChannel,
reject: () => void,
) => {
if (info.destPort !== actualPort) {
reject();
return;
}
const inbound = accept();
if (mode === "dynamic") {
handleSocks5Connect(
inbound,
(host, port) => forwardOut(outboundClient, host, port),
name,
log,
);
return;
}
pipeTunnelStreams(
inbound,
forwardOut(outboundClient, targetHost, targetPort),
name,
log,
);
};
bindClient.on("tcp connection", onTcp);
// Losing the endpoint leg makes the tunnel useless, so let the source
// close too and the usual disconnect handling take it from there.
endpointClient.once("close", () => {
if (runtimes.get(name)?.endpointClient === endpointClient) {
source.dispose();
}
});
log.info(
`Tunnel ${name} bound ${bindHost}:${actualPort} (${mode}) to ${targetHost}:${targetPort}`,
);
return {
sourceClient,
endpointClient,
bindHost,
bindPort: actualPort,
close: () => {
bindClient.off("tcp connection", onTcp);
unbindForwardIn(bindClient, bindHost, actualPort, log);
endpoint.dispose();
source.dispose();
},
};
}
async function establish(config: TunnelConfig): Promise<TunnelRuntime> {
const source = await openLeg(config.requestingUserId, config.sourceHostId);
try {
return shouldEstablishDirectTunnel(config)
? await establishDirect(source, config)
: await establishManaged(source, config);
} catch (error) {
source.dispose();
throw error;
}
}
/** Reacts to the source connection dropping after the tunnel was up. */
function watch(runtime: TunnelRuntime, config: TunnelConfig): void {
const name = config.name;
const client = runtime.sourceClient;
client.on("error", (error: Error) => {
log.warn(`Tunnel ${name} connection error: ${error.message}`);
});
client.once("close", () => {
if (runtimes.get(name)?.sourceClient !== client) return;
if (retryTimers.has(name)) return;
if (!manualDisconnects.has(name)) {
const current = statuses.get(name);
if (current?.status !== CONNECTION_STATES.FAILED) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
});
}
}
void handleDisconnect(name, config, !manualDisconnects.has(name), client);
});
}
async function handleDisconnect(
name: string,
config: TunnelConfig | null,
shouldRetry = true,
closingClient?: Client,
): Promise<void> {
if (isReservedTunnelName(name)) {
// On-demand tunnels are reopened by whoever needs them, never retried
// here. A deferred close for a client that a reopen already replaced
// must not tear down the replacement.
const current = runtimes.get(name);
if (closingClient && current && current.sourceClient !== closingClient) {
return;
}
await cleanup(name, true);
return;
}
while (cleanupInProgress.has(name)) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await cleanup(name);
if (manualDisconnects.has(name)) {
resetRetryState(name);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
return;
}
// Past the manual branch, so this is a drop nobody asked for.
if (config?.requestingUserId) {
ctx.events.emit("plugin.tunnels.tunnel_disconnected", {
userId: config.requestingUserId,
hostId: config.sourceHostId,
tunnelName: name,
});
}
if (retryExhausted.has(name)) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Max retries already exhausted",
});
return;
}
if (retryTimers.has(name) || disposed) return;
if (!shouldRetry || !config) {
// Keep the reason the failure was just reported with.
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: lastErrors.get(name),
errorType: lastErrorTypes.get(name),
});
return;
}
const maxRetries = config.maxRetries || 3;
const retryInterval = config.retryInterval || 5000;
const retryCount = (retryCounters.get(name) || 0) + 1;
if (retryCount > maxRetries) {
log.error(`All ${maxRetries} retries failed for ${name}`);
retryExhausted.add(name);
retryCounters.delete(name);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
retryExhausted: true,
reason: "Max retries exhausted",
});
return;
}
retryCounters.set(name, retryCount);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.RETRYING,
retryCount,
maxRetries,
nextRetryIn: retryInterval / 1000,
});
let nextRetryIn = Math.ceil(retryInterval / 1000);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.WAITING,
retryCount,
maxRetries,
nextRetryIn,
});
countdownTimers.set(
name,
setInterval(() => {
nextRetryIn--;
if (nextRetryIn > 0) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.WAITING,
retryCount,
maxRetries,
nextRetryIn,
});
}
}, 1000),
);
retryTimers.set(
name,
setTimeout(() => {
clearRetryTimers(name);
if (!manualDisconnects.has(name)) {
void connect(config, retryCount);
}
}, retryInterval),
);
}
/**
* Opens a tunnel. Failures are reported over the status broadcast and
* retried there, so by default this never rejects. `throwOnError` is for a
* caller that waits on the result (forward()), which gets the runtime back.
*/
async function connect(
config: TunnelConfig,
retryAttempt = 0,
options: { throwOnError?: boolean } = {},
): Promise<TunnelRuntime | null> {
const name = config.name;
if (manualDisconnects.has(name) || disposed) return null;
connecting.add(name);
await cleanup(name, true);
if (retryAttempt === 0) {
retryExhausted.delete(name);
retryCounters.delete(name);
}
const current = statuses.get(name);
if (!current || current.status !== CONNECTION_STATES.WAITING) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.CONNECTING,
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
});
}
try {
if (getTunnelScope(config) !== "s2s") {
throw new Error(
"Client tunnels must be started from the desktop app's local configuration",
);
}
const runtime = await establish(config);
connecting.delete(name);
if (manualDisconnects.has(name) || disposed) {
runtime.close();
return null;
}
runtimes.set(name, runtime);
watch(runtime, config);
broadcast(name, { connected: true, status: CONNECTION_STATES.CONNECTED });
return runtime;
} catch (error) {
connecting.delete(name);
const message = errorMessage(error, "Failed to create tunnel");
const errorType = classifyTunnelError(message);
log.error(
`Tunnel ${name} failed to connect: ${message}`,
error instanceof Error ? error : undefined,
);
if (!manualDisconnects.has(name)) {
broadcast(name, {
connected: false,
status: CONNECTION_STATES.FAILED,
errorType,
reason: message,
});
}
const shouldNotRetry =
errorType === "AUTHENTICATION_FAILED" ||
errorType === "CONNECTION_FAILED" ||
manualDisconnects.has(name) ||
options.throwOnError === true;
await handleDisconnect(name, config, !shouldNotRetry);
if (options.throwOnError) throw error;
return null;
}
}
/** Stops a tunnel on request. Holds off retries for a few seconds. */
async function stop(name: string): Promise<void> {
const config = configs.get(name) ?? null;
manualDisconnects.add(name);
retryCounters.delete(name);
retryExhausted.delete(name);
clearRetryTimers(name);
await cleanup(name, true);
broadcast(name, {
connected: false,
status: CONNECTION_STATES.DISCONNECTED,
manualDisconnect: true,
});
await handleDisconnect(name, config, false);
const hold = setTimeout(() => {
manualHoldTimers.delete(hold);
manualDisconnects.delete(name);
}, MANUAL_DISCONNECT_HOLD_MS);
manualHoldTimers.add(hold);
}
/**
* Starts a configured tunnel by name, waiting for any operation already in
* flight for it first. The start itself is not awaited: its outcome comes
* back over the status stream.
*/
async function start(config: TunnelConfig): Promise<void> {
const name = config.name;
const pending = pendingOperations.get(name);
if (pending) {
await pending.catch(() => undefined);
}
const existing = configs.get(name);
if (
existing &&
(existing.sourceHostId !== config.sourceHostId ||
existing.tunnelIndex !== config.tunnelIndex)
) {
throw new Error(`Tunnel name collision detected: ${name}`);
}
manualDisconnects.delete(name);
retryCounters.delete(name);
retryExhausted.delete(name);
configs.set(name, config);
const operation = connect(config, 0).then(() => undefined);
pendingOperations.set(name, operation);
void operation.finally(() => {
if (pendingOperations.get(name) === operation) {
pendingOperations.delete(name);
}
});
}
/** Whether `userId` can reach `hostId`, cached briefly so broadcasts stay cheap. */
async function canAccessHost(
userId: string,
hostId: number,
): Promise<boolean> {
const cacheKey = `${userId}:${hostId}`;
const cached = accessCache.get(cacheKey);
if (cached && Date.now() - cached.at < ACCESS_CACHE_MS) {
return cached.allowed;
}
let allowed = false;
try {
const access =
ctx.currentActor() === userId
? await ctx.hosts.checkAccess(hostId, "connect")
: await ctx.asUser(userId, () =>
ctx.hosts.checkAccess(hostId, "connect"),
);
allowed = access.hasAccess;
} catch {
allowed = false;
}
accessCache.set(cacheKey, { allowed, at: Date.now() });
return allowed;
}
/**
* Status of every tunnel whose source host the user can reach. Names carry
* host labels and endpoints and errors can name internal hosts, so nothing
* else is shown. On-demand tunnels are never listed.
*/
async function statusesFor(userId: string): Promise<TunnelStatusMap> {
const result: TunnelStatusMap = {};
for (const [name, status] of statuses) {
if (isReservedTunnelName(name)) continue;
const hostId = configs.get(name)?.sourceHostId;
if (hostId === undefined) continue;
if (await canAccessHost(userId, hostId)) result[name] = status;
}
return result;
}
async function canAccessTunnel(
userId: string,
name: string,
): Promise<boolean> {
const hostId = configs.get(name)?.sourceHostId;
if (hostId === undefined) return false;
return canAccessHost(userId, hostId);
}
function onChange(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function dispose(): void {
disposed = true;
for (const name of [...runtimes.keys()]) {
const runtime = runtimes.get(name);
runtimes.delete(name);
try {
runtime?.close();
} catch {
// Already gone.
}
}
for (const name of [...retryTimers.keys(), ...countdownTimers.keys()]) {
clearRetryTimers(name);
}
for (const hold of manualHoldTimers) clearTimeout(hold);
manualHoldTimers.clear();
listeners.clear();
}
return {
configs,
statuses,
runtimes,
manualDisconnects,
connecting,
broadcast,
cleanup,
connect,
start,
stop,
handleDisconnect,
statusesFor,
canAccessHost,
canAccessTunnel,
onChange,
dispose,
};
}
+133
View File
@@ -0,0 +1,133 @@
import { and, asc, eq, ne } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface PresetRecord {
id: number;
userId: string;
name: string;
config: string;
platform: string | null;
computerName: string | null;
createdAt: string;
updatedAt: string;
}
export interface PresetCreateInput {
name: string;
config: string;
platform?: string | null;
computerName?: string | null;
}
export type PresetUpdateInput = Partial<PresetCreateInput>;
/* eslint-disable @typescript-eslint/no-explicit-any */
// The table comes from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type PresetRepository = ReturnType<typeof createPresetRepository>;
/**
* Per-user client tunnel presets.
*
* No RETURNING, so it runs the same on all three engines: a new row is read
* back as the newest row for that user and name, which the name check before
* every insert keeps unique.
*/
export function createPresetRepository(db: PluginDatabase, table: Table) {
const client = () => db.client<Drizzle>();
async function findByIdForUser(
userId: string,
id: number,
): Promise<PresetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return (rows[0] as PresetRecord) ?? null;
}
return {
findByIdForUser,
async listByUserId(userId: string): Promise<PresetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(eq(table.userId, userId))
.orderBy(asc(table.name));
},
async hasNameForUser(
userId: string,
name: string,
excludingId?: number,
): Promise<boolean> {
const drizzle = await client();
const conditions = [eq(table.userId, userId), eq(table.name, name)];
if (excludingId !== undefined) conditions.push(ne(table.id, excludingId));
const rows = await drizzle
.select({ id: table.id })
.from(table)
.where(and(...conditions))
.limit(1);
return rows.length > 0;
},
async createForUser(
userId: string,
input: PresetCreateInput,
now = new Date().toISOString(),
): Promise<PresetRecord> {
const drizzle = await client();
await drizzle.insert(table).values({
userId,
name: input.name,
config: input.config,
platform: input.platform ?? null,
computerName: input.computerName ?? null,
createdAt: now,
updatedAt: now,
});
await db.persist();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.name, input.name)))
.limit(1);
return rows[0] as PresetRecord;
},
async updateForUser(
userId: string,
id: number,
updates: PresetUpdateInput,
now = new Date().toISOString(),
): Promise<PresetRecord | null> {
const drizzle = await client();
await drizzle
.update(table)
.set({ ...updates, updatedAt: now })
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return findByIdForUser(userId, id);
},
async deleteForUser(userId: string, id: number): Promise<boolean> {
if (!(await findByIdForUser(userId, id))) return false;
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.id, id), eq(table.userId, userId)));
await db.persist();
return true;
},
};
}
+588
View File
@@ -0,0 +1,588 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
CONNECTION_STATES,
type TunnelConnectRequest,
type TunnelConnection,
} from "./types.js";
import type { TunnelManager } from "./manager.js";
import { errorMessage } from "./manager.js";
import type { PresetRecord, PresetRepository } from "./repository.js";
import {
RESERVED_TUNNEL_NAME_PREFIX,
isReservedTunnelName,
validateTunnelConfig,
} from "./utils.js";
import { authorizeTunnelAction } from "./authorize.js";
import { buildTunnelConfig, resolveEndpoint } from "./config.js";
function actor(ctx: PluginContext): string {
// Core's plugin router authenticates every request and runs it as that user.
return ctx.currentActor() as string;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function parsePreset(row: PresetRecord) {
let config: TunnelConnection[] = [];
try {
config = JSON.parse(row.config) as TunnelConnection[];
} catch {
config = [];
}
return { ...row, config };
}
/** Every item must be a client tunnel with a known mode and valid ports. */
export function validatePresetConfig(
config: unknown,
): config is TunnelConnection[] {
if (!Array.isArray(config)) return false;
return config.every((item) => {
if (!item || typeof item !== "object") return false;
const tunnel = item as Partial<TunnelConnection>;
const mode = tunnel.mode || tunnel.tunnelType;
return (
tunnel.scope === "c2s" &&
(mode === "local" || mode === "remote" || mode === "dynamic") &&
typeof tunnel.sourcePort === "number" &&
tunnel.sourcePort >= 1 &&
tunnel.sourcePort <= 65535 &&
(mode === "dynamic" ||
(typeof tunnel.endpointPort === "number" &&
tunnel.endpointPort >= 1 &&
tunnel.endpointPort <= 65535))
);
});
}
function trimmedOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
/**
* Mounts the tunnel routes on the plugin's router, which core serves at
* /plugin-api/tunnels with auth in front. Every route needs tunnels.use.
*/
export function registerTunnelRoutes(
router: Router,
ctx: PluginContext,
manager: TunnelManager,
presets: PresetRepository,
): void {
router.use(ctx.rbac.require("use") as never);
const statusClients = new Map<Response, string>();
const sendSnapshot = (res: Response) => {
const userId = statusClients.get(res);
if (userId === undefined) return;
void manager
.statusesFor(userId)
.then((snapshot) => {
res.write(`event: statuses\ndata: ${JSON.stringify(snapshot)}\n\n`);
})
.catch(() => {
statusClients.delete(res);
});
};
const unsubscribe = manager.onChange(() => {
for (const res of statusClients.keys()) sendSnapshot(res);
});
ctx.disposables.add(() => {
unsubscribe();
for (const res of statusClients.keys()) {
try {
res.end();
} catch {
// Already closed.
}
}
statusClients.clear();
});
const canAccess = (userId: string) => (hostId: number) =>
manager.canAccessHost(userId, hostId);
/**
* @openapi
* /plugin-api/tunnels/status:
* get:
* summary: Get tunnel statuses
* description: Returns the status of every tunnel whose source host the caller can reach.
* tags:
* - Tunnels
* responses:
* 200:
* description: Tunnel statuses keyed by tunnel name.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/status", async (_req: Request, res: Response) => {
res.json(await manager.statusesFor(actor(ctx)));
});
/**
* @openapi
* /plugin-api/tunnels/status/stream:
* get:
* summary: Stream tunnel statuses
* description: Server-sent events. Sends a "statuses" event with the full snapshot the caller can see on connect and after every change.
* tags:
* - Tunnels
* responses:
* 200:
* description: An event stream.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/status/stream", (req: Request, res: Response) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
statusClients.set(res, actor(ctx));
sendSnapshot(res);
const heartbeat = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
closeStream();
}
}, 30_000);
const closeStream = () => {
clearInterval(heartbeat);
statusClients.delete(res);
};
req.on("close", closeStream);
});
/**
* @openapi
* /plugin-api/tunnels/status/{tunnelName}:
* get:
* summary: Get one tunnel's status
* tags:
* - Tunnels
* parameters:
* - in: path
* name: tunnelName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The tunnel's status.
* 404:
* description: No such tunnel, or the caller cannot see it.
*/
router.get("/status/:tunnelName", async (req: Request, res: Response) => {
const tunnelName = String(req.params.tunnelName);
// 404 rather than 403 for someone else's tunnel: the name itself carries
// host details, so confirming it exists would leak them.
if (!(await manager.canAccessTunnel(actor(ctx), tunnelName))) {
return res.status(404).json({ error: "Tunnel not found" });
}
const status = manager.statuses.get(tunnelName);
if (!status) return res.status(404).json({ error: "Tunnel not found" });
res.json({ name: tunnelName, status });
});
/**
* @openapi
* /plugin-api/tunnels/connect:
* post:
* summary: Connect a tunnel
* description: Starts a server tunnel from one of the caller's hosts. Credentials are resolved on the server. The result arrives over the status stream.
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* sourceHostId:
* type: integer
* tunnelIndex:
* type: integer
* mode:
* type: string
* endpointHost:
* type: string
* sourcePort:
* type: integer
* endpointPort:
* type: integer
* responses:
* 200:
* description: Connection request received.
* 400:
* description: Invalid tunnel configuration.
* 403:
* description: Access denied to this host.
*/
router.post("/connect", async (req: Request, res: Response) => {
const userId = actor(ctx);
const body = (req.body ?? {}) as Partial<TunnelConnectRequest>;
const tunnelName = body.name;
if (!isNonEmptyString(tunnelName)) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
// Reserved for on-demand forwards, which skip retries by name. A saved
// tunnel using it would silently lose its own reconnects.
if (isReservedTunnelName(tunnelName)) {
return res.status(400).json({
error: `Tunnel names beginning with "${RESERVED_TUNNEL_NAME_PREFIX}" are reserved`,
});
}
const sourceHostId = Number(body.sourceHostId);
const tunnelIndex = Number(body.tunnelIndex ?? 0);
if (!Number.isInteger(sourceHostId) || !Number.isInteger(tunnelIndex)) {
return res.status(400).json({ error: "Invalid tunnel configuration" });
}
const request: TunnelConnectRequest = {
...(body as TunnelConnectRequest),
endpointHost: (body.endpointHost ?? "").trim(),
name: tunnelName,
sourceHostId,
tunnelIndex,
};
if (
!validateTunnelConfig(tunnelName, {
sourceHostId,
tunnelIndex,
sourcePort: request.sourcePort,
endpointHost: request.endpointHost ?? "",
endpointPort: request.endpointPort,
})
) {
return res
.status(400)
.json({ error: "Tunnel configuration does not match tunnel name" });
}
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
if (!host) {
ctx.log.warn(
`User ${userId} tried to connect tunnel ${tunnelName} without access to host ${sourceHostId}`,
);
return res.status(403).json({ error: "Access denied to this host" });
}
try {
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(host, request, userId),
);
await manager.start(config);
} catch (error) {
const reason = errorMessage(error);
ctx.log.warn(`Tunnel ${tunnelName} could not start: ${reason}`);
manager.broadcast(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason,
});
}
res.json({ message: "Connection request received", tunnelName });
});
const stopHandler =
(message: string) => async (req: Request, res: Response) => {
const userId = actor(ctx);
const tunnelName = (req.body ?? {}).tunnelName;
if (!isNonEmptyString(tunnelName)) {
return res.status(400).json({ error: "Tunnel name required" });
}
const decision = await authorizeTunnelAction(
canAccess(userId),
tunnelName,
manager.configs.get(tunnelName),
);
if (!decision.allowed) {
return res.status(403).json({ error: "Access denied" });
}
await manager.stop(tunnelName);
res.json({ message, tunnelName });
};
/**
* @openapi
* /plugin-api/tunnels/disconnect:
* post:
* summary: Disconnect a tunnel
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Disconnect request received.
* 400:
* description: Tunnel name required.
* 403:
* description: Access denied.
*/
router.post("/disconnect", stopHandler("Disconnect request received"));
/**
* @openapi
* /plugin-api/tunnels/cancel:
* post:
* summary: Cancel a tunnel's connect or retry
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tunnelName:
* type: string
* responses:
* 200:
* description: Cancel request received.
* 400:
* description: Tunnel name required.
* 403:
* description: Access denied.
*/
router.post("/cancel", stopHandler("Cancel request received"));
/**
* @openapi
* /plugin-api/tunnels/presets:
* get:
* summary: List client tunnel presets
* description: Returns the caller's saved client-to-server tunnel presets.
* tags:
* - Tunnels
* responses:
* 200:
* description: List of tunnel presets.
* 403:
* description: Missing the tunnels.use permission.
*/
router.get("/presets", async (_req: Request, res: Response) => {
try {
const rows = await presets.listByUserId(actor(ctx));
res.json(rows.map(parsePreset));
} catch (error) {
ctx.log.error(
"Failed to fetch client tunnel presets",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets:
* post:
* summary: Create a client tunnel preset
* tags:
* - Tunnels
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* config:
* type: array
* items:
* type: object
* platform:
* type: string
* computerName:
* type: string
* responses:
* 201:
* description: Preset created.
* 400:
* description: Invalid name or config.
* 409:
* description: A preset with that name already exists.
*/
router.post("/presets", async (req: Request, res: Response) => {
const userId = actor(ctx);
const { name, config, platform, computerName } = req.body ?? {};
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
if (!validatePresetConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
const trimmedName = name.trim();
try {
if (await presets.hasNameForUser(userId, trimmedName)) {
return res.status(409).json({ error: "Preset name already exists" });
}
const created = await presets.createForUser(userId, {
name: trimmedName,
config: JSON.stringify(config),
platform: trimmedOrNull(platform),
computerName: trimmedOrNull(computerName),
});
res.status(201).json(parsePreset(created));
} catch (error) {
ctx.log.error(
"Failed to create client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets/{id}:
* put:
* summary: Update a client tunnel preset
* tags:
* - Tunnels
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Preset updated.
* 400:
* description: Invalid name or config.
* 404:
* description: Preset not found.
* 409:
* description: A preset with that name already exists.
*/
router.put("/presets/:id", async (req: Request, res: Response) => {
const userId = actor(ctx);
const id = Number(req.params.id);
const { name, config, platform, computerName } = req.body ?? {};
if (!Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await presets.findByIdForUser(userId, id);
if (!existing) return res.status(404).json({ error: "Preset not found" });
const updates: Parameters<PresetRepository["updateForUser"]>[2] = {};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
if (await presets.hasNameForUser(userId, trimmedName, id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updates.name = trimmedName;
}
if (config !== undefined) {
if (!validatePresetConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
updates.config = JSON.stringify(config);
}
if (platform !== undefined) updates.platform = trimmedOrNull(platform);
if (computerName !== undefined) {
updates.computerName = trimmedOrNull(computerName);
}
const updated = await presets.updateForUser(userId, id, updates);
res.json(parsePreset(updated ?? existing));
} catch (error) {
ctx.log.error(
"Failed to update client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
}
});
/**
* @openapi
* /plugin-api/tunnels/presets/{id}:
* delete:
* summary: Delete a client tunnel preset
* tags:
* - Tunnels
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Preset deleted.
* 404:
* description: Preset not found.
*/
router.delete("/presets/:id", async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
if (!(await presets.deleteForUser(actor(ctx), id))) {
return res.status(404).json({ error: "Preset not found" });
}
res.json({ success: true });
} catch (error) {
ctx.log.error(
"Failed to delete client tunnel preset",
error instanceof Error ? error : undefined,
);
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
}
});
}
+260
View File
@@ -0,0 +1,260 @@
import type { Client } from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TunnelConfig, TunnelStatus } from "./types.js";
import type { TunnelManager, TunnelRuntime } from "./manager.js";
import { forwardOut } from "./ssh-primitives.js";
import {
RESERVED_TUNNEL_NAME_PREFIX,
isReservedTunnelName,
parseReservedTunnelName,
} from "./utils.js";
import { authorizeTunnelAction } from "./authorize.js";
import {
buildTunnelConfig,
connectionToRequest,
findSavedTunnel,
hostLabel,
resolveEndpoint,
} from "./config.js";
export interface TunnelForwardTarget {
targetHost: string;
targetPort: number;
/** Where the local listener binds. Defaults to 127.0.0.1. */
bindHost?: string;
/** A fixed local port. Defaults to one the kernel picks. */
bindPort?: number;
}
export interface TunnelForwardHandle {
bindHost: string;
bindPort: number;
close: () => Promise<void>;
}
/** What other plugins get from ctx.services.get("tunnels.access"). */
export interface TunnelsAccess {
/**
* Opens (or reuses) a local forward to target through a source host and
* resolves only once it works: connected, listening and the target
* answered one probe. Rejects with the real reason otherwise.
*/
forward(
sourceHostId: number,
target: TunnelForwardTarget,
options?: { name?: string; idleTimeoutMs?: number },
): Promise<TunnelForwardHandle>;
/** Starts a saved tunnel by name. Its outcome arrives as status. */
start(name: string): Promise<void>;
/** Stops a tunnel by name and holds off its retries. */
stop(name: string): Promise<void>;
status(name: string): Promise<TunnelStatus | null>;
list(): Promise<Record<string, TunnelStatus>>;
}
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/**
* Opens one channel to the target and closes it again. Without it a forward
* reports success the moment its listener is up, and the likeliest failure
* (nothing listening on the target port) only shows up as a blank page.
*/
async function probeTarget(
client: Client,
targetHost: string,
targetPort: number,
): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
const channel = await Promise.race([
forwardOut(client, targetHost, targetPort),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error("Timed out reaching the endpoint port")),
TARGET_PROBE_TIMEOUT_MS,
);
}),
]);
try {
channel.end();
} catch {
// Already gone; the probe succeeded either way.
}
} finally {
if (timer) clearTimeout(timer);
}
}
function isPort(value: unknown, allowZero = false): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= (allowZero ? 0 : 1) &&
value <= 65535
);
}
export function createTunnelsService(
ctx: PluginContext,
manager: TunnelManager,
): TunnelsAccess {
// What each live forward was opened for, so an edited target reopens it
// instead of silently forwarding to the old one.
const fingerprints = new Map<string, string>();
const actor = (): string => {
const userId = ctx.currentActor();
if (!userId) throw new Error("No acting user");
return userId;
};
const handleFor = (
name: string,
runtime: TunnelRuntime,
): TunnelForwardHandle => ({
bindHost: runtime.bindHost,
bindPort: runtime.bindPort,
close: async () => {
fingerprints.delete(name);
await manager.cleanup(name, true);
},
});
const canAccess = (hostId: number) => manager.canAccessHost(actor(), hostId);
return {
async forward(sourceHostId, target, options = {}) {
const userId = actor();
if (!isPort(target.targetPort)) throw new Error("Invalid target port");
if (target.bindPort !== undefined && !isPort(target.bindPort, true)) {
throw new Error("Invalid bind port");
}
const targetHost = target.targetHost?.trim();
if (!targetHost) throw new Error("Invalid target host");
const access = await ctx.hosts.checkAccess(sourceHostId, "connect");
const host = access.hasAccess ? await ctx.hosts.get(sourceHostId) : null;
if (!host) throw new Error("Host not found or access denied");
const name =
options.name ??
`${RESERVED_TUNNEL_NAME_PREFIX}${sourceHostId}:${targetHost}:${target.targetPort}`;
if (
!isReservedTunnelName(name) ||
parseReservedTunnelName(name)?.hostId !== sourceHostId
) {
throw new Error(
`Forward names must be "${RESERVED_TUNNEL_NAME_PREFIX}<hostId>:<id>" for the source host`,
);
}
const bindHost = target.bindHost?.trim() || "127.0.0.1";
const fingerprint = JSON.stringify({
targetHost,
targetPort: target.targetPort,
bindHost,
bindPort: target.bindPort ?? null,
ip: host.ip,
sshPort: host.port,
username: host.username,
});
const existing = manager.runtimes.get(name);
if (existing) {
const recorded = fingerprints.get(name);
if (recorded === undefined || recorded === fingerprint) {
fingerprints.set(name, fingerprint);
return handleFor(name, existing);
}
ctx.log.info(`Reopening tunnel ${name} after its target changed`);
await manager.cleanup(name, true);
fingerprints.delete(name);
}
const config: TunnelConfig = {
name,
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost,
targetHost,
sourceHostId: host.id,
tunnelIndex: 0,
requestingUserId: userId,
hostName: hostLabel(host),
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
// Loopback marks it single host, which is what picks the
// listen-here-and-forwardOut path.
endpointHost: "127.0.0.1",
sourcePort: target.bindPort ?? 0,
endpointPort: target.targetPort,
maxRetries: 0,
retryInterval: 0,
autoStart: false,
idleTimeoutMs: options.idleTimeoutMs,
};
manager.manualDisconnects.delete(name);
try {
const runtime = await manager.connect(config, 0, {
throwOnError: true,
});
if (!runtime) {
throw new Error("The tunnel closed before it could be used");
}
await probeTarget(runtime.sourceClient, targetHost, target.targetPort);
fingerprints.set(name, fingerprint);
return handleFor(name, runtime);
} catch (error) {
// Nothing half-built may be handed out on the next call.
await manager.cleanup(name, true).catch(() => undefined);
fingerprints.delete(name);
throw error;
}
},
async start(name) {
const existing = manager.configs.get(name);
if (existing) {
if (!(await canAccess(existing.sourceHostId))) {
throw new Error(`Tunnel "${name}" is not configured`);
}
await manager.start(existing);
return;
}
const saved = await findSavedTunnel(ctx, name);
if (!saved) throw new Error(`Tunnel "${name}" is not configured`);
const config = await resolveEndpoint(
ctx,
buildTunnelConfig(
saved.host,
connectionToRequest(saved.host, saved.index, saved.connection),
actor(),
),
);
await manager.start(config);
},
async stop(name) {
const decision = await authorizeTunnelAction(
canAccess,
name,
manager.configs.get(name),
);
if (!decision.allowed) throw new Error("Access denied");
await manager.stop(name);
},
async status(name) {
if (!(await manager.canAccessTunnel(actor(), name))) return null;
return manager.statuses.get(name) ?? null;
},
async list() {
return manager.statusesFor(actor());
},
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { Duplex } from "node:stream";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
function parseSocksAddress(buffer: Buffer): {
address: string;
port: number;
bytesRead: number;
} | null {
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
return null;
}
const addressType = buffer[3];
let offset = 4;
let address: string;
if (addressType === 0x01) {
if (buffer.length < offset + 4 + 2) return null;
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
offset += 4;
} else if (addressType === 0x03) {
const len = buffer[offset];
offset += 1;
if (buffer.length < offset + len + 2) return null;
address = buffer.subarray(offset, offset + len).toString("utf8");
offset += len;
} else if (addressType === 0x04) {
if (buffer.length < offset + 16 + 2) return null;
const parts: string[] = [];
for (let i = 0; i < 16; i += 2) {
parts.push(buffer.readUInt16BE(offset + i).toString(16));
}
address = parts.join(":");
offset += 16;
} else {
return null;
}
const port = buffer.readUInt16BE(offset);
return { address, port, bytesRead: offset + 2 };
}
/** The server side of a SOCKS5 CONNECT, for dynamic mode tunnels. */
export function handleSocks5Connect(
inbound: Duplex,
openOutbound: (host: string, port: number) => Promise<Duplex>,
tunnelName: string,
log?: PluginLogger,
): void {
let buffer = Buffer.alloc(0);
let stage: "greeting" | "connect" | "piping" = "greeting";
const fail = (code = 0x01) => {
if (!inbound.destroyed) {
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
inbound.destroy();
}
};
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
if (stage === "greeting") {
if (buffer.length < 2) return;
if (buffer[0] !== 0x05) {
fail(0x01);
return;
}
const methodsLength = buffer[1];
if (buffer.length < 2 + methodsLength) return;
inbound.write(Buffer.from([0x05, 0x00]));
buffer = buffer.subarray(2 + methodsLength);
stage = "connect";
}
if (stage === "connect") {
const parsed = parseSocksAddress(buffer);
if (!parsed) return;
stage = "piping";
inbound.off("data", onData);
const remainder = buffer.subarray(parsed.bytesRead);
openOutbound(parsed.address, parsed.port)
.then((outbound) => {
inbound.write(
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
);
if (remainder.length > 0) {
outbound.write(remainder);
}
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error: unknown) => {
log?.warn(
`SOCKS5 connect to ${parsed.address}:${parsed.port} failed for tunnel ${tunnelName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
fail(0x05);
});
}
};
inbound.on("data", onData);
inbound.on("error", () => inbound.destroy());
}
@@ -0,0 +1,78 @@
import type { Client, ClientChannel } from "ssh2";
import type { Duplex } from "node:stream";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
// Plain ssh2 channel operations on a client that already came from ctx.ssh.
export function forwardOut(
client: Client,
targetHost: string,
targetPort: number,
): Promise<ClientChannel> {
return new Promise((resolve, reject) => {
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
if (err) {
reject(err);
return;
}
resolve(stream);
});
});
}
export function bindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
): Promise<number> {
return new Promise((resolve, reject) => {
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
if (err) {
reject(err);
return;
}
resolve(actualPort || bindPort);
});
});
}
export function unbindForwardIn(
client: Client,
bindHost: string,
bindPort: number,
log?: PluginLogger,
): void {
try {
client.unforwardIn(bindHost, bindPort, (err) => {
if (err) {
log?.warn(
`Failed to unbind tunnel listener ${bindHost}:${bindPort}: ${err.message}`,
);
}
});
} catch {
// The connection may already be gone.
}
}
export function pipeTunnelStreams(
inbound: Duplex,
outboundPromise: Promise<Duplex>,
tunnelName: string,
log?: PluginLogger,
): void {
outboundPromise
.then((outbound) => {
inbound.pipe(outbound).pipe(inbound);
inbound.on("error", () => outbound.destroy());
outbound.on("error", () => inbound.destroy());
})
.catch((error: unknown) => {
log?.warn(
`Tunnel ${tunnelName} could not open its outbound stream: ${
error instanceof Error ? error.message : String(error)
}`,
);
inbound.destroy();
});
}
+32
View File
@@ -0,0 +1,32 @@
import {
adoptLegacyTable,
defineTable,
id,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* Client tunnel presets the desktop app saves per user.
*
* Adopted from core's c2s_tunnel_presets, so the column names are the legacy
* ones and the rename carries every existing row across. Core never indexed
* it, so there is no legacy index to keep.
*/
export const presets = adoptLegacyTable(
"c2s_tunnel_presets",
defineTable("presets", {
id: id(),
userId: refUser(),
name: text().notNull(),
// JSON: TunnelConnection[]
config: text().notNull(),
platform: text(),
computerName: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
}),
);
export const tables = [presets];
+60
View File
@@ -0,0 +1,60 @@
import type { TunnelMode, TunnelScope } from "../shared/types.js";
export type {
C2STunnelPreset,
ConnectionState,
TunnelConnectRequest,
TunnelConnection,
TunnelErrorType,
TunnelMode,
TunnelScope,
TunnelStatus,
} from "../shared/types.js";
export { CONNECTION_STATES } from "../shared/types.js";
/**
* A tunnel as the manager runs it. Built server side from a saved connection
* or a connect request; it never carries credentials; both SSH legs resolve
* theirs through ctx.ssh.
*/
export interface TunnelConfig {
name: string;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
targetHost?: string;
sourceHostId: number;
sourceHostSyncId?: string;
tunnelIndex: number;
/** Who the SSH connections run as. */
requestingUserId: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
endpointHost: string;
/** The Termix host the endpoint leg connects to, when there is one. */
endpointHostId?: number;
endpointIP?: string;
endpointSSHPort?: number;
endpointUsername?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
/** Milliseconds. */
retryInterval: number;
autoStart: boolean;
/**
* When set, the tunnel closes itself once it has had no connected sockets
* for this long. forward() uses it for on-demand tunnels.
*/
idleTimeoutMs?: number;
}
+265
View File
@@ -0,0 +1,265 @@
import { buildTunnelName } from "../shared/tunnel-naming.js";
import type { TunnelConfig, TunnelErrorType } from "./types.js";
export function classifyTunnelError(errorMessage: string): TunnelErrorType {
if (!errorMessage) return "UNKNOWN";
const message = errorMessage.toLowerCase();
if (
message.includes("closed by remote host") ||
message.includes("connection reset by peer") ||
message.includes("connection refused") ||
message.includes("broken pipe")
) {
return "NETWORK_ERROR";
}
if (
message.includes("authentication failed") ||
message.includes("authentication methods failed") ||
message.includes("permission denied") ||
message.includes("incorrect password")
) {
return "AUTHENTICATION_FAILED";
}
if (
message.includes("connect etimedout") ||
message.includes("timeout") ||
message.includes("timed out") ||
message.includes("keepalive timeout")
) {
return "TIMEOUT";
}
if (
message.includes("bind: address already in use") ||
message.includes("failed for listen port") ||
message.includes("port forwarding failed")
) {
return "CONNECTION_FAILED";
}
if (message.includes("permission") || message.includes("access denied")) {
return "CONNECTION_FAILED";
}
return "UNKNOWN";
}
/** The name a host's saved tunnel runs under. See shared/tunnel-naming.ts. */
export const normalizeTunnelName = buildTunnelName;
export function getTunnelMode(
tunnelConfig: Pick<TunnelConfig, "mode" | "tunnelType">,
): "local" | "remote" | "dynamic" {
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
}
export function getTunnelScope(
tunnelConfig: Pick<TunnelConfig, "scope">,
): "s2s" | "c2s" {
return tunnelConfig.scope || "s2s";
}
export function getTunnelBindHost(
tunnelConfig: Pick<TunnelConfig, "bindHost">,
): string {
return tunnelConfig.bindHost || "127.0.0.1";
}
export function parseTunnelName(tunnelName: string): {
hostId?: number;
tunnelIndex?: number;
displayName: string;
sourcePort: string;
endpointHost: string;
endpointPort: string;
isLegacyFormat: boolean;
} {
const parts = tunnelName.split("::");
if (parts.length === 6) {
return {
hostId: parseInt(parts[0]),
tunnelIndex: parseInt(parts[1]),
displayName: parts[2],
sourcePort: parts[3],
endpointHost: parts[4],
endpointPort: parts[5],
isLegacyFormat: false,
};
}
const legacyParts = tunnelName.split("_");
return {
displayName: legacyParts[0] || "unknown",
sourcePort: legacyParts[legacyParts.length - 3] || "0",
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
endpointPort: legacyParts[legacyParts.length - 1] || "0",
isLegacyFormat: true,
};
}
export function validateTunnelConfig(
tunnelName: string,
tunnelConfig: Pick<
TunnelConfig,
| "sourceHostId"
| "tunnelIndex"
| "sourcePort"
| "endpointHost"
| "endpointPort"
>,
): boolean {
const parsed = parseTunnelName(tunnelName);
if (parsed.isLegacyFormat) {
return true;
}
return (
parsed.hostId === tunnelConfig.sourceHostId &&
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
parsed.endpointHost === tunnelConfig.endpointHost &&
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
);
}
/**
* Tunnel names beginning with this prefix are reserved for forward() tunnels
* (web endpoints): they are opened on demand and never retried on disconnect.
* A user-supplied name using the prefix would silently lose its own retry
* behaviour and could collide with a live forward, so POST /connect rejects
* it.
*/
export const RESERVED_TUNNEL_NAME_PREFIX = "web:";
export function isReservedTunnelName(tunnelName: string): boolean {
return tunnelName.startsWith(RESERVED_TUNNEL_NAME_PREFIX);
}
/** The exact inverse of parseReservedTunnelName. */
export function buildWebEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return `${RESERVED_TUNNEL_NAME_PREFIX}${hostId}:${endpointId}`;
}
/**
* Recovers the host id and endpoint id from a reserved name, so a route that
* only has the name can still check ownership.
*
* Returns null for anything that is not a reserved name or whose host id is
* not a positive integer. Callers must treat null as "cannot verify" and deny.
* The endpoint id is everything after the first ":" following the host id,
* so it may contain colons itself.
*/
export function parseReservedTunnelName(
tunnelName: string,
): { hostId: number; endpointId: string } | null {
if (!isReservedTunnelName(tunnelName)) return null;
const rest = tunnelName.slice(RESERVED_TUNNEL_NAME_PREFIX.length);
const separatorIndex = rest.indexOf(":");
if (separatorIndex === -1) return null;
const hostIdPart = rest.slice(0, separatorIndex);
const endpointId = rest.slice(separatorIndex + 1);
if (!/^[0-9]+$/.test(hostIdPart) || !endpointId) return null;
const hostId = Number(hostIdPart);
if (!Number.isInteger(hostId) || hostId < 1) return null;
return { hostId, endpointId };
}
type EndpointHostLike = {
id: number | string;
name?: string | null;
ip: string;
username?: string | null;
};
/** Matches a saved endpointHost string against a host id, name, ip or user@ip. */
export function findHostByTunnelEndpoint<T extends EndpointHostLike>(
hosts: T[],
endpointHost?: string | null,
): T | undefined {
const value = endpointHost?.trim();
if (!value) return undefined;
return hosts.find((host) => {
const userAtIp = host.username ? `${host.username}@${host.ip}` : "";
return (
String(host.id) === value ||
host.name === value ||
host.ip === value ||
userAtIp === value
);
});
}
export function isSingleHostTunnel(
tunnelConfig: Pick<
TunnelConfig,
| "endpointHost"
| "endpointIP"
| "sourceIP"
| "endpointSSHPort"
| "sourceSSHPort"
| "endpointHostId"
| "sourceHostId"
>,
): boolean {
if (!tunnelConfig.endpointHost && !tunnelConfig.endpointIP) return true;
if (
tunnelConfig.endpointHost === "127.0.0.1" ||
tunnelConfig.endpointHost === "localhost"
) {
return true;
}
if (
tunnelConfig.endpointHostId !== undefined &&
tunnelConfig.endpointHostId === tunnelConfig.sourceHostId
) {
return true;
}
if (
tunnelConfig.endpointIP &&
tunnelConfig.endpointIP === tunnelConfig.sourceIP &&
tunnelConfig.endpointSSHPort === tunnelConfig.sourceSSHPort
) {
return true;
}
return false;
}
/**
* Direct tunnels ride the source connection alone. Anything else needs a
* second SSH leg to a Termix endpoint host, reached through the source.
*/
export function shouldEstablishDirectTunnel(
tunnelConfig: TunnelConfig,
): boolean {
if (isSingleHostTunnel(tunnelConfig)) return true;
const mode = getTunnelMode(tunnelConfig);
return mode !== "remote" && tunnelConfig.endpointHostId === undefined;
}
export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string {
const targetHost = tunnelConfig.targetHost?.trim();
if (
!targetHost ||
targetHost === tunnelConfig.endpointHost ||
targetHost === tunnelConfig.hostName
) {
return "127.0.0.1";
}
return targetHost;
}
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { getTunnelStatuses } from "./api";
/** The dashboard's "active tunnels" counter; a click opens the tunnels tab. */
export function ActiveTunnelsCounter({ onOpen }: { onOpen: () => void }) {
const { t } = useTranslation();
const [count, setCount] = useState(0);
useEffect(() => {
let cancelled = false;
getTunnelStatuses()
.then((statuses) => {
if (cancelled) return;
setCount(
Object.values(statuses ?? {}).filter(
(status) =>
(status as { status?: string })?.status?.toUpperCase() ===
"CONNECTED",
).length,
);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
return (
<button
onClick={onOpen}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
>
<Network className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-base font-bold">{count}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("tunnels.activeTunnels")}
</span>
</button>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,372 @@
import { useEffect, useState } from "react";
import { Network, Plus } from "lucide-react";
import {
useHosts,
useToast,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
Button,
FakeSwitch,
Input,
SectionCard,
SettingRow,
} from "@termix/plugin-sdk/ui";
import type { TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
import {
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
parseConnections,
tunnelMode,
} from "./host-tunnels";
type PluginSettingsForm = Record<string, Record<string, unknown>>;
function newTunnel(): TunnelConnection {
return {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "",
endpointPort: 80,
bindHost: "127.0.0.1",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
};
}
const MODE_DESCRIPTION_KEYS = {
local: "hosts.tunnelModeLocalDesc",
remote: "hosts.tunnelModeRemoteDesc",
dynamic: "hosts.tunnelModeDynamicDesc",
} as const;
const numberInputClass =
"h-7 text-xs [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
/**
* The host editor's Tunnels 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.
*/
export function HostTunnelsSection({
form,
updateForm,
host,
}: HostEditorSectionProps) {
const { t } = useTranslation();
const toast = useToast();
const { hosts } = useHosts();
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [busyIndex, setBusyIndex] = useState<number | null>(null);
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
const settings = ((form?.pluginSettings as PluginSettingsForm | undefined)
?.tunnels ?? {}) as Record<string, unknown>;
const enabled = settings.enableTunnel === true;
const tunnels = parseConnections(settings.tunnelConnections);
const setSettings = (patch: Record<string, unknown>) =>
updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return {
...current,
pluginSettings: {
...all,
tunnels: { ...(all.tunnels ?? {}), ...patch },
},
};
});
const setTunnels = (next: TunnelConnection[]) =>
setSettings({ tunnelConnections: next });
const updateTunnel = (index: number, patch: Partial<TunnelConnection>) =>
setTunnels(
tunnels.map((tunnel, i) =>
i === index ? { ...tunnel, ...patch } : tunnel,
),
);
const sourceHost = host
? {
id: host.id,
name: (form?.name as string) || host.name,
username: (form?.username as string) || host.username,
ip: (form?.ip as string) || host.ip,
}
: null;
const toggle = async (index: number, connected: boolean) => {
if (!sourceHost) return;
const tunnel = tunnels[index];
setBusyIndex(index);
try {
if (connected) {
await disconnectTunnel(serverTunnelName(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelDisconnected"));
} else {
await connectTunnel(connectRequestFor(sourceHost, index, tunnel));
toast.success(t("hosts.tunnelConnecting"));
}
} catch {
toast.error(
connected
? t("hosts.failedToDisconnectTunnel")
: t("hosts.failedToConnectTunnel"),
);
} finally {
setBusyIndex(null);
}
};
return (
<>
<SectionCard
title={t("hosts.tunnelSettings")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTunneling")}
description={
<>
{t("hosts.enableTunnelingDesc")}{" "}
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={enabled}
onChange={(value) => setSettings({ enableTunnel: value })}
/>
</SettingRow>
<div className="text-xs text-muted-foreground p-3 bg-muted/30 border border-border space-y-1">
<p>{t("hosts.tunnelRequirementsText")}</p>
</div>
</div>
</SectionCard>
<SectionCard
title={t("hosts.serverTunnelsSection")}
icon={<Network className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() => setTunnels([...tunnels, newTunnel()])}
>
<Plus className="size-3 mr-1" /> {t("hosts.addTunnelBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
{tunnels.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 px-1">
{t("hosts.noTunnelsConfigured")}
</p>
)}
{tunnels.map((tunnel, i) => {
const mode = tunnelMode(tunnel);
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
const status = sourceHost
? statuses[serverTunnelName(sourceHost, i, tunnel)]?.status
: undefined;
const isConnected = status === "connected";
return (
<div
key={i}
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">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">
{t("hosts.tunnelLabel", { number: i + 1 })}
</span>
<div
className={`size-1.5 rounded-full shrink-0 ${
isConnected
? "bg-accent-brand shadow-[0_0_4px_rgba(251,146,60,0.4)]"
: status === "failed"
? "bg-red-400"
: "bg-muted-foreground/25"
}`}
title={status ?? t("tunnels.disconnected")}
/>
{sourceHost && (
<Button
variant="outline"
size="sm"
disabled={busyIndex === i}
className={`h-6 text-[10px] px-2 ${isConnected ? "border-destructive/40 text-destructive hover:bg-destructive/10" : "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"}`}
onClick={() => toggle(i, isConnected)}
>
{busyIndex === i
? "..."
: isConnected
? t("hosts.disconnectBtn")
: t("hosts.connectBtn")}
</Button>
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive"
onClick={() =>
setTunnels(tunnels.filter((_, idx) => idx !== i))
}
>
{t("common.delete")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.tunnelType")}
</label>
<div className="flex gap-2">
{(["remote", "local", "dynamic"] as const).map((m) => (
<button
key={m}
onClick={() => updateTunnel(i, { mode: m })}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m}
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
{t(MODE_DESCRIPTION_KEYS[mode])}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointHost")}
</label>
<Input
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
list={endpointInputId}
placeholder={t("hosts.endpointHostPlaceholder")}
value={tunnel.endpointHost ?? ""}
onChange={(e) =>
updateTunnel(i, { endpointHost: e.target.value })
}
/>
<datalist id={endpointInputId}>
{hosts
.filter((h) => h.enableSsh !== false)
.map((h) => (
<option key={h.id} value={h.ip}>
{h.name || h.ip} ({h.ip})
</option>
))}
</datalist>
</div>
)}
{mode !== "dynamic" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.endpointPort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.endpointPort}
onChange={(e) =>
updateTunnel(i, {
endpointPort: Number(e.target.value),
})
}
/>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.bindHost")}
</label>
<Input
className="h-7 text-xs"
placeholder="127.0.0.1"
value={tunnel.bindHost ?? ""}
onChange={(e) =>
updateTunnel(i, { bindHost: e.target.value })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.sourcePort")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.sourcePort}
onChange={(e) =>
updateTunnel(i, { sourcePort: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.maxRetries")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.maxRetries}
onChange={(e) =>
updateTunnel(i, { maxRetries: Number(e.target.value) })
}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-muted-foreground">
{t("hosts.retryIntervalS")}
</label>
<Input
className={numberInputClass}
type="number"
value={tunnel.retryInterval}
onChange={(e) =>
updateTunnel(i, {
retryInterval: Number(e.target.value),
})
}
/>
</div>
</div>
<SettingRow
label={t("hosts.autoStartLabel")}
description={t("hosts.autoStartDesc")}
>
<FakeSwitch
checked={tunnel.autoStart}
onChange={(value) => updateTunnel(i, { autoStart: value })}
/>
</SettingRow>
</div>
);
})}
</div>
</SectionCard>
</>
);
}
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from "react";
import type { PluginSshPromptRequest } from "@termix/plugin-sdk/backend";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Input,
} from "@termix/plugin-sdk/ui";
type Challenge = {
id: string;
tunnelName?: string;
request: PluginSshPromptRequest;
};
export function TunnelAuthPrompts() {
const [queue, setQueue] = useState<Challenge[]>([]);
const pending = useRef(new Set<string>());
useEffect(() => {
const active = pending.current;
const api = window.electronAPI;
if (!api?.onC2SAuthPrompt) return;
const dispose = api.onC2SAuthPrompt((event) => {
if (event.closed) {
active.delete(event.id);
setQueue((items) => items.filter((item) => item.id !== event.id));
} else if (event.request && !active.has(event.id)) {
active.add(event.id);
setQueue((items) => [...items, { ...event, request: event.request! }]);
}
});
return () => {
dispose();
for (const id of active) void api.answerC2SAuth(id, null);
active.clear();
};
}, []);
const current = queue[0];
return current ? (
<AuthDialog
key={current.id}
challenge={current}
onAnswer={(answer) => {
pending.current.delete(current.id);
setQueue((items) => items.filter((item) => item.id !== current.id));
void window.electronAPI.answerC2SAuth(current.id, answer);
}}
/>
) : null;
}
function AuthDialog({
challenge,
onAnswer,
}: {
challenge: Challenge;
onAnswer: (answer: string | null) => void;
}) {
const { t } = useTranslation();
const [answer, setAnswer] = useState("");
const request = challenge.request;
const browser = request.kind === "browser";
const url =
browser && request.url && /^https?:\/\//i.test(request.url)
? request.url
: null;
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onAnswer(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("tunnels.authTitle")}</DialogTitle>
</DialogHeader>
<p className="text-sm break-all">{challenge.tunnelName}</p>
<form
onSubmit={(event) => {
event.preventDefault();
onAnswer(answer);
}}
className="space-y-3"
>
<p className="text-sm whitespace-pre-wrap">
{browser ? request.instructions : request.prompt}
</p>
{request.kind === "totp" && request.retry && (
<p role="alert">{t("tunnels.authRetry")}</p>
)}
{browser ? (
<>
<p>{request.code}</p>
{url && (
<a
href={url}
target="_blank"
rel="noreferrer"
className="break-all underline"
>
{url}
</a>
)}
</>
) : (
<Input
autoFocus
aria-label={t("tunnels.authAnswer")}
autoComplete="off"
type={
request.kind === "input" && request.echo ? "text" : "password"
}
value={answer}
onChange={(event) => setAnswer(event.target.value)}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onAnswer(null)}
>
{t("common.cancel")}
</Button>
<Button type="submit">{t("tunnels.authContinue")}</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
Clock,
ExternalLink,
Network,
Play,
RefreshCw,
Settings,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import {
logActivity,
useHost,
useToast,
useTranslation,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { Button, Card } from "@termix/plugin-sdk/ui";
import type { TunnelConnection, TunnelStatus } from "../shared/types";
import { serverTunnelName, tunnelHostLabel } from "../shared/tunnel-naming";
import {
cancelTunnel,
connectTunnel,
disconnectTunnel,
subscribeTunnelStatuses,
type TunnelStatusMap,
} from "./api";
import {
connectRequestFor,
hostTunnelSettings,
tunnelMode,
} from "./host-tunnels";
type StatusLabel =
"CONNECTED" | "CONNECTING" | "ERROR" | "WAITING" | "DISCONNECTED";
function statusLabel(status: TunnelStatus | undefined): StatusLabel {
const value = status?.status?.toUpperCase();
if (value === "CONNECTED") return "CONNECTED";
if (value === "CONNECTING" || value === "VERIFYING") return "CONNECTING";
if (value === "FAILED") return "ERROR";
if (value === "RETRYING" || value === "WAITING") return "WAITING";
return "DISCONNECTED";
}
const STATUS_TEXT_KEYS: Record<StatusLabel, string> = {
CONNECTED: "tunnels.connected",
CONNECTING: "tunnels.connecting",
ERROR: "tunnels.error",
WAITING: "tunnels.waiting",
DISCONNECTED: "tunnels.disconnected",
};
function TunnelCard({
host,
tunnel,
status,
isActing,
onAction,
}: {
host: PluginHostRecord;
tunnel: TunnelConnection;
status: TunnelStatus | undefined;
isActing: boolean;
onAction: (action: "connect" | "disconnect" | "cancel") => void;
}) {
const { t } = useTranslation();
const [showSettings, setShowSettings] = useState(false);
const label = statusLabel(status);
const isConnected = label === "CONNECTED";
const isConnecting = label === "CONNECTING";
const isError = label === "ERROR";
const isWaiting = label === "WAITING";
let statusColor = "text-muted-foreground border-border bg-muted/30";
if (isConnected)
statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10";
if (isConnecting)
statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10";
if (isError)
statusColor = "text-destructive border-destructive/40 bg-destructive/10";
if (isWaiting)
statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10";
const mode = tunnelMode(tunnel);
const destination =
mode === "dynamic"
? t("tunnels.socksProxy")
: `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`;
return (
<Card className="flex flex-col overflow-hidden p-0 gap-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" />
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("tunnels.port")} {tunnel.sourcePort}
</span>
</div>
<div
className={`flex items-center gap-1.5 px-2 py-0.5 border text-[10px] font-bold uppercase ${statusColor}`}
>
{isConnecting || isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : isConnected ? (
<Wifi className="size-3" />
) : isError ? (
<AlertCircle className="size-3" />
) : isWaiting ? (
<Clock className="size-3" />
) : (
<WifiOff className="size-3" />
)}
{isActing ? t("tunnels.working") : t(STATUS_TEXT_KEYS[label])}
</div>
</div>
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">
{t("tunnels.destination")}
</span>
<span
className="text-sm font-mono font-semibold truncate"
title={destination}
>
{destination}
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{mode}
</span>
<span className="text-[10px] text-muted-foreground">
{t("tunnels.listensOn", { port: tunnel.sourcePort })}
</span>
</div>
</div>
{isError && status?.reason && (
<div className="flex items-start gap-2 p-2 bg-destructive/5 border border-destructive/20 text-destructive text-[10px]">
<AlertCircle className="size-3 mt-0.5 shrink-0" />
<span>{status.reason}</span>
</div>
)}
{showSettings && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-2 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.host")}
</span>
<span className="font-mono">{tunnelHostLabel(host)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.mode")}
</span>
<span className="uppercase font-bold">{mode}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.localPort")}
</span>
<span className="font-mono">{tunnel.sourcePort}</span>
</div>
{mode !== "dynamic" && (
<>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remoteHost")}
</span>
<span className="font-mono">{tunnel.endpointHost}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remotePort")}
</span>
<span className="font-mono">{tunnel.endpointPort}</span>
</div>
</>
)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.maxRetries")}
</span>
<span className="font-mono">{tunnel.maxRetries}</span>
</div>
</div>
)}
<div className="flex gap-2 mt-1">
{isConnected ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive gap-1.5"
disabled={isActing}
onClick={() => onAction("disconnect")}
>
<Square className="size-3" />
{t("tunnels.stop")}
</Button>
) : isConnecting || isWaiting ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-yellow-500 border-yellow-500/40 hover:bg-yellow-500/10 hover:text-yellow-500 gap-1.5"
disabled={isActing}
onClick={() => onAction("cancel")}
>
<Square className="size-3" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand gap-1.5"
disabled={isActing}
onClick={() => onAction("connect")}
>
{isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{t("tunnels.start")}
</Button>
)}
<Button
variant={showSettings ? "secondary" : "ghost"}
size="icon"
className={`h-8 w-8 ${showSettings ? "bg-accent-brand/10 text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setShowSettings((s) => !s)}
>
<Settings className="size-3.5" />
</Button>
</div>
</div>
</Card>
);
}
/** A host's saved server tunnels, with live status and start/stop controls. */
export function TunnelTab({ host: given }: { host?: PluginHostRecord }) {
const { t } = useTranslation();
const toast = useToast();
const live = useHost(given?.id);
const host = live ?? given ?? null;
const [statuses, setStatuses] = useState<TunnelStatusMap>({});
const [acting, setActing] = useState<Record<string, boolean>>({});
useEffect(() => subscribeTunnelStatuses(setStatuses), []);
// One recent-activity entry per host opened here.
const hostId = host?.id;
const hostLabel = host ? tunnelHostLabel(host) : "";
useEffect(() => {
if (typeof hostId !== "number") return;
logActivity("tunnel", hostId, hostLabel).catch(() => {});
}, [hostId]); // eslint-disable-line react-hooks/exhaustive-deps
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 bg-muted/40 flex items-center justify-center">
<Network className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t("tunnels.noHostSelected")}
</span>
</div>
);
}
const tunnels = hostTunnelSettings(host).connections;
const names = tunnels.map((tunnel, index) =>
serverTunnelName(host, index, tunnel),
);
const connectedCount = names.filter(
(name) => statuses[name]?.status === "connected",
).length;
const handleAction = async (
action: "connect" | "disconnect" | "cancel",
index: number,
) => {
const tunnel = tunnels[index];
if (!tunnel) return;
const name = names[index];
setActing((current) => ({ ...current, [name]: true }));
try {
if (action === "connect") {
await connectTunnel(connectRequestFor(host, index, tunnel));
toast.success(t("tunnels.clientTunnelStarted"));
} else if (action === "disconnect") {
await disconnectTunnel(name);
toast.info(t("tunnels.clientTunnelStopped"));
} else {
await cancelTunnel(name);
toast.info(t("tunnels.canceling"));
}
} catch {
toast.error(t("tunnels.manualControlError"));
} finally {
setActing((current) => ({ ...current, [name]: false }));
}
};
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{host.name}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("tunnels.activeCount", {
connected: connectedCount,
total: tunnels.length,
})}
</span>
</div>
</div>
</div>
<a
href="https://docs.termix.site/features/networking/tunnels"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
title={t("hosts.docsLink")}
>
<ExternalLink className="size-4" />
</a>
</Card>
{tunnels.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{tunnels.map((tunnel, index) => (
<TunnelCard
key={names[index]}
host={host}
tunnel={tunnel}
status={statuses[names[index]]}
isActing={acting[names[index]] ?? false}
onAction={(action) => handleAction(action, index)}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4 py-20">
<div className="opacity-10 flex flex-col items-center gap-4">
<Network className="size-16" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("tunnels.noSshTunnels")}
</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
)}
</div>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type {
C2STunnelPreset,
TunnelConnectRequest,
TunnelConnection,
TunnelStatus,
} from "../shared/types";
export type TunnelStatusMap = Record<string, TunnelStatus>;
let client: PluginApiClient | null = null;
let remoteClient: PluginApiClient | null = null;
let openStream: ((init: RequestInit) => Promise<Response>) | null = null;
export interface TunnelsTransport {
/** app.fetch for the status stream, which carries core's auth. */
stream?: (init: RequestInit) => Promise<Response>;
/** The connected remote server's client, on the desktop app. */
remote?: PluginApiClient | null;
}
/** Set from activate with app.api, cleared on deactivate. */
export function setTunnelsApi(
api: PluginApiClient | null,
transport: TunnelsTransport = {},
): void {
client = api;
remoteClient = api ? (transport.remote ?? null) : null;
openStream = api ? (transport.stream ?? null) : null;
}
function api(): PluginApiClient {
if (!client) throw new Error("The tunnels plugin is not active");
return client;
}
/**
* The server's own reason for a failed call, or the fallback. The routes
* always answer { error: string }.
*/
export function apiErrorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string" && data.error) return data.error;
return fallback;
}
// Tunnel status is a process-local, in-memory view, so reading it is always
// safe. connectTunnel/disconnectTunnel/cancelTunnel always target the local
// embedded backend: they address a host by numeric id, and a synced host has
// a different numeric id in each database (only its syncId matches), so
// routing them to a remote backend would need a local-to-remote id lookup
// that does not exist yet.
export async function getTunnelStatuses(): Promise<TunnelStatusMap> {
const response = await api().get<TunnelStatusMap>("/status");
return response.data || {};
}
/**
* Local statuses win a name collision with remote ones, since every control
* targets the local backend.
*/
export function mergeTunnelStatuses(
local: TunnelStatusMap,
remote: TunnelStatusMap,
): TunnelStatusMap {
return { ...remote, ...local };
}
export interface ServerSentEvent {
event: string;
data: string;
}
/** Splits a text/event-stream body into events. Returns what is left over. */
export function parseServerSentEvents(
buffer: string,
onEvent: (event: ServerSentEvent) => void,
): string {
const blocks = buffer.split(/\r?\n\r?\n/);
const rest = blocks.pop() ?? "";
for (const block of blocks) {
let event = "message";
const data: string[] = [];
for (const line of block.split(/\r?\n/)) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (data.length > 0) onEvent({ event, data: data.join("\n") });
}
return rest;
}
function streamUrl(): string | null {
const base = (client as unknown as { defaults?: { baseURL?: string } })
?.defaults?.baseURL;
if (base === undefined) return null;
return `${base.replace(/\/$/, "")}/plugin-api/tunnels/status/stream`;
}
export interface SubscribeOptions {
/** Replaces the default fetch, for tests. */
fetchImpl?: typeof fetch;
/** Statuses from a connected remote server, polled and merged in. */
fetchRemote?: () => Promise<TunnelStatusMap>;
pollIntervalMs?: number;
}
/**
* Live tunnel statuses over the plugin's event stream, reconnecting after a
* drop. Falls back to polling when the stream URL cannot be worked out.
*/
export function subscribeTunnelStatuses(
onStatuses: (statuses: TunnelStatusMap) => void,
onError?: () => void,
options: SubscribeOptions = {},
): () => void {
const controller = new AbortController();
const fetchImpl = options.fetchImpl ?? fetch;
const pollIntervalMs = options.pollIntervalMs ?? 5000;
let latestLocal: TunnelStatusMap = {};
let latestRemote: TunnelStatusMap = {};
const timers = new Set<ReturnType<typeof setTimeout>>();
const emit = () => onStatuses(mergeTunnelStatuses(latestLocal, latestRemote));
const wait = (ms: number) =>
new Promise<void>((resolve) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve();
}, ms);
timers.add(timer);
});
// A test hands in fetchImpl and a base URL; the app hands in app.fetch.
const url = options.fetchImpl ? streamUrl() : null;
const connect: ((init: RequestInit) => Promise<Response>) | null = url
? (init) => fetchImpl(url, { credentials: "include", ...init })
: openStream;
const fetchRemote =
options.fetchRemote ??
(remoteClient
? async () => {
const response = await remoteClient!.get<TunnelStatusMap>("/status");
return response.data || {};
}
: undefined);
void (async () => {
while (!controller.signal.aborted) {
if (!connect) {
try {
latestLocal = await getTunnelStatuses();
emit();
} catch {
onError?.();
}
await wait(pollIntervalMs);
continue;
}
try {
const response = await connect({
headers: { Accept: "text/event-stream" },
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!controller.signal.aborted) {
const { value, done } = await reader.read();
if (done) break;
buffer = parseServerSentEvents(
buffer + decoder.decode(value, { stream: true }),
(event) => {
if (event.event !== "statuses") return;
try {
latestLocal = JSON.parse(event.data) as TunnelStatusMap;
emit();
} catch {
onError?.();
}
},
);
}
if (!controller.signal.aborted) onError?.();
} catch {
if (!controller.signal.aborted) onError?.();
}
if (!controller.signal.aborted) await wait(1000);
}
})();
if (fetchRemote) {
void (async () => {
while (!controller.signal.aborted) {
try {
latestRemote = await fetchRemote();
emit();
} catch {
// A remote that is down just contributes nothing.
}
await wait(pollIntervalMs);
}
})();
}
return () => {
controller.abort();
for (const timer of timers) clearTimeout(timer);
timers.clear();
};
}
export async function connectTunnel(
request: TunnelConnectRequest,
): Promise<void> {
await api().post("/connect", request);
}
export async function disconnectTunnel(tunnelName: string): Promise<void> {
await api().post("/disconnect", { tunnelName });
}
export async function cancelTunnel(tunnelName: string): Promise<void> {
await api().post("/cancel", { tunnelName });
}
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
const response = await api().get<C2STunnelPreset[]>("/presets");
return response.data || [];
}
export async function createC2STunnelPreset(data: {
name: string;
config: TunnelConnection[];
platform?: string;
computerName?: string;
}): Promise<C2STunnelPreset> {
const response = await api().post<C2STunnelPreset>("/presets", data);
return response.data;
}
export async function updateC2STunnelPreset(
id: number,
data: Partial<{
name: string;
config: TunnelConnection[];
platform: string;
computerName: string;
}>,
): Promise<C2STunnelPreset> {
const response = await api().put<C2STunnelPreset>(`/presets/${id}`, data);
return response.data;
}
export async function deleteC2STunnelPreset(id: number): Promise<void> {
await api().delete(`/presets/${id}`);
}
@@ -0,0 +1,70 @@
import type { TunnelConnection, TunnelMode } from "../shared/types";
export type ClientTunnel = TunnelConnection & {
localAddress: string;
remoteAddress: string;
bindHost: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
displayName?: string;
lastStartedAt?: string;
lastTestedAt?: string;
lastError?: string;
};
export function getTunnelMode(tunnel: Partial<TunnelConnection>): TunnelMode {
return tunnel.mode || tunnel.tunnelType || "local";
}
export function normalizeClientTunnel(
tunnel: Partial<ClientTunnel>,
): ClientTunnel {
const mode = getTunnelMode(tunnel);
const metadata = tunnel as Partial<ClientTunnel>;
const localAddress =
tunnel.localAddress?.trim() || tunnel.bindHost?.trim() || "";
const remoteAddress =
tunnel.remoteAddress?.trim() || tunnel.targetHost?.trim() || "";
return {
...tunnel,
scope: "c2s",
mode,
tunnelType: mode === "dynamic" ? "local" : mode,
localAddress,
remoteAddress,
bindHost: localAddress,
targetHost: remoteAddress,
sourcePort: Number(tunnel.sourcePort) || 8080,
endpointPort: Number(tunnel.endpointPort) || 22,
sourceHostSyncId: metadata.sourceHostSyncId,
endpointHost: tunnel.endpointHost || tunnel.sourceHostName || "",
maxRetries: Number(tunnel.maxRetries) || 3,
retryInterval: Number(tunnel.retryInterval) || 10,
autoStart: Boolean(tunnel.autoStart),
displayName: metadata.displayName ?? "",
lastStartedAt: metadata.lastStartedAt,
lastTestedAt: metadata.lastTestedAt,
lastError: metadata.lastError,
};
}
export function stripClientTunnelDiagnostics(
tunnel: ClientTunnel,
): TunnelConnection {
const presetTunnel = normalizeClientTunnel(tunnel);
const displayName = presetTunnel.displayName?.trim();
if (displayName) {
presetTunnel.displayName = displayName;
} else {
delete presetTunnel.displayName;
}
delete presetTunnel.lastStartedAt;
delete presetTunnel.lastTestedAt;
delete presetTunnel.lastError;
return presetTunnel;
}
@@ -0,0 +1,65 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { TunnelConnectRequest, TunnelConnection } from "../shared/types";
import { serverTunnelName } from "../shared/tunnel-naming";
/** A host's tunnel settings, as the host payload's pluginSettings carries them. */
export function hostTunnelSettings(host: PluginHostRecord | null | undefined): {
enabled: boolean;
connections: TunnelConnection[];
} {
const settings = (
host?.pluginSettings as Record<string, Record<string, unknown>> | undefined
)?.tunnels;
return {
enabled: settings?.enableTunnel === true,
connections: parseConnections(settings?.tunnelConnections),
};
}
export function parseConnections(value: unknown): TunnelConnection[] {
let parsed = value;
if (typeof parsed === "string") {
try {
parsed = JSON.parse(parsed);
} catch {
return [];
}
}
return Array.isArray(parsed) ? (parsed as TunnelConnection[]) : [];
}
export function tunnelMode(
tunnel: Pick<TunnelConnection, "mode" | "tunnelType">,
): "local" | "remote" | "dynamic" {
return tunnel.mode ?? tunnel.tunnelType ?? "local";
}
/** What POST /connect takes for a host's saved tunnel. */
export function connectRequestFor(
host: {
id: string | number;
name?: string | null;
username?: string | null;
ip: string;
},
index: number,
tunnel: TunnelConnection,
): TunnelConnectRequest {
const mode = tunnelMode(tunnel);
return {
name: serverTunnelName(host, index, tunnel),
sourceHostId: Number(host.id),
tunnelIndex: index,
scope: tunnel.scope ?? "s2s",
mode,
tunnelType: mode === "remote" ? "remote" : "local",
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
endpointHost: (tunnel.endpointHost ?? "").trim(),
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort ?? 0,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval,
autoStart: tunnel.autoStart,
};
}
+124
View File
@@ -0,0 +1,124 @@
import type { ComponentType } from "react";
import { Network, Plug } from "lucide-react";
import type { TabProps, TermixApp } from "@termix/plugin-sdk/frontend";
import { TunnelAuthPrompts } from "./TunnelAuthPrompts";
import { TunnelTab } from "./TunnelTab";
import { HostTunnelsSection } from "./HostTunnelsSection";
import {
PortForwardingPanel,
TunnelStandalone,
TunnelWidget,
TunnelWidgetEditForm,
} from "./views";
import { setTunnelsApi } from "./api";
import { ActiveTunnelsCounter } from "./ActiveTunnelsCounter";
import { hostTunnelSettings } from "./host-tunnels";
// Matches the homepage canvas grid.
const GRID_SIZE = 30;
function TunnelTabView({ host }: TabProps) {
return <TunnelTab host={host} />;
}
/** This plugin's C2S relay socket, which the desktop app dials on the linked server. */
const C2S_RELAY_PATH = "/plugin-ws/tunnels/c2s/stream";
export function activate(app: TermixApp): void {
if (typeof window !== "undefined" && window.electronAPI?.setC2SRelayPath) {
void window.electronAPI.setC2SRelayPath(C2S_RELAY_PATH);
}
app.registerSlotContribution("shell.overlay", {
actionId: "tunnels.authPrompts",
titleKey: "tunnels.authTitle",
kind: "component",
component: TunnelAuthPrompts as ComponentType<Record<string, unknown>>,
});
// apiFor("remote") hands back app.api itself outside the desktop app.
const remote = app.apiFor("remote");
setTunnelsApi(app.api, {
stream: (init) => app.fetch("/status/stream", init),
remote: remote === app.api ? null : remote,
});
app.onDispose(() => setTunnelsApi(null));
// Keeps the "tunnel" tab type and ?view=tunnel links from before the
// conversion, so saved layouts and recent activity still open here.
app.registerTab("tunnel", TunnelTabView as ComponentType<TabProps>, {
icon: Network,
titleKey: "nav.tunnels",
persistent: true,
hostless: true,
standalone: TunnelStandalone,
activityTypes: ["tunnel"],
});
app.registerHostAction({
id: "tunnel",
titleKey: "nav.tunnels",
icon: Network,
kind: "open",
order: 40,
tabType: "tunnel",
copyUrlView: "tunnel",
when: (host) =>
host.enableSsh !== false && hostTunnelSettings(host).enabled,
});
app.registerHostEditorSection({
id: "tunnels",
group: "ssh",
titleKey: "hosts.tabTunnels",
icon: Network,
order: 20,
component: HostTunnelsSection,
});
app.registerPanel("port-forwarding", PortForwardingPanel);
app.registerRailItem({
id: "port-forwarding",
icon: Network,
titleKey: "nav.portForwarding",
kind: "panel",
electronOnly: true,
separatorAfter: true,
after: "credentials",
permission: "use",
});
app.registerSlotContribution("onboarding.features", {
actionId: "tunnels.feature",
titleKey: "onboarding.feature_tunnels",
descriptionKey: "onboarding.feature_tunnels_desc",
icon: Plug as ComponentType<{ className?: string }>,
});
app.registerExtension("homepage.widgets", {
id: "tunnel_widget",
name: "Tunnel Manager",
description: "Embedded SSH tunnel manager for a configured host",
category: "system",
icon: <Network size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
components: { view: TunnelWidget, editForm: TunnelWidgetEditForm },
});
// The dashboard's active tunnel counter.
app.registerAction(
"tunnels.open",
(() => app.tabs.openSingletonTab("tunnel")) as never,
{ permission: "use" },
);
app.registerSlotContribution("dashboard.counters", {
actionId: "tunnels.open",
titleKey: "tunnels.activeTunnels",
kind: "component",
component: (() => (
<ActiveTunnelsCounter
onOpen={() => void app.tabs.openSingletonTab("tunnel")}
/>
)) as never,
});
}
@@ -0,0 +1,64 @@
import type { TunnelMode } from "../shared/types";
type Translate = (
key: string,
options?: Record<string, string | number>,
) => string;
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
return mode === "remote" ? "remote" : "local";
}
export function getTunnelPortLabels(
scope: "client" | "server",
mode: TunnelMode,
t: Translate,
) {
if (scope === "client") {
return {
sourcePortLabel:
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
endpointPortLabel:
mode === "remote"
? t("tunnels.localPort")
: t("tunnels.destinationPort"),
};
}
return {
sourcePortLabel: t("tunnels.currentHostPort"),
endpointPortLabel: t("tunnels.endpointPort"),
};
}
export function getTunnelModeDescription(
scope: "client" | "server",
mode: TunnelMode,
ports: {
sourcePort: string | number;
endpointPort: string | number;
},
t: Translate,
) {
if (scope === "client") {
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionClientDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionClientLocal", ports);
}
return t("tunnels.forwardDescriptionClientRemote", ports);
}
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionServerDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionServerLocal", ports);
}
return t("tunnels.forwardDescriptionServerRemote", ports);
}
+151
View File
@@ -0,0 +1,151 @@
import { Network } from "lucide-react";
import {
useHosts,
useTranslation,
type PluginHostRecord,
type StandaloneViewProps,
} from "@termix/plugin-sdk/frontend";
import {
ConnectionScreen,
FullScreenAppWrapper,
Select2,
} from "@termix/plugin-sdk/ui";
import { TunnelTab } from "./TunnelTab";
import { C2STunnelPresetManager } from "./C2STunnelPresetManager";
import { hostTunnelSettings } from "./host-tunnels";
/** The `?view=tunnel` full-screen page for one host. */
export function TunnelStandalone({ hostId }: StandaloneViewProps) {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
const host = {
...hostConfig,
id: String(hostConfig.id),
} as unknown as PluginHostRecord;
return <TunnelTab host={host} />;
}}
</FullScreenAppWrapper>
);
}
/** The rail panel for the desktop app's client tunnels. */
export function PortForwardingPanel() {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-2">
<C2STunnelPresetManager />
</div>
);
}
export interface TunnelWidgetConfig {
hostId: number;
}
/** Homepage widget: the tunnel tab for one host. */
export function TunnelWidget({
widget,
config,
}: {
widget: { title?: string | null };
config: TunnelWidgetConfig;
}) {
const { t } = useTranslation();
const { hosts, loaded } = useHosts();
const placeholder = (text: string) => (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-xs text-muted-foreground/60">
<Network size={20} />
<span>{text}</span>
</div>
);
if (!config.hostId) return placeholder(t("common.noHostConfigured"));
if (!loaded) return placeholder(t("common.loading"));
const host = hosts.find(
(candidate) =>
String(candidate.id) === String(config.hostId) &&
hostTunnelSettings(candidate).enabled,
);
if (!host) return placeholder(t("common.noHostConfigured"));
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{widget.title && (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
<span className="text-accent-brand shrink-0">
<Network size={11} />
</span>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{widget.title}
</span>
</div>
)}
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<TunnelTab host={host} />
</div>
</div>
);
}
/** The widget's edit form: pick a host with tunnels on. */
export function TunnelWidgetEditForm({
config,
onChange,
}: {
config: TunnelWidgetConfig;
onChange: (config: TunnelWidgetConfig) => void;
}) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && hostTunnelSettings(host).enabled,
);
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("common.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("common.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}
@@ -0,0 +1,76 @@
/**
* Tunnel names, shared by the backend and every frontend view so the two
* sides can never build the same tunnel under different names.
*/
/** A host's saved server tunnel: `<hostId>::<index>::<label>::<port>::<endpoint>::<port>`. */
export function buildTunnelName(
hostId: number | string,
tunnelIndex: number,
hostLabel: string,
sourcePort: number | string,
endpointHost: string,
endpointPort: number | string,
): string {
return `${hostId}::${tunnelIndex}::${hostLabel}::${sourcePort}::${endpointHost}::${endpointPort}`;
}
/** The label a server tunnel name carries for its source host. */
export function tunnelHostLabel(host: {
name?: string | null;
username?: string | null;
ip: string;
}): string {
return host.name || `${host.username ?? ""}@${host.ip}`;
}
/** A saved server tunnel's name, from its host and position in the list. */
export function serverTunnelName(
host: {
id: number | string;
name?: string | null;
username?: string | null;
ip: string;
},
index: number,
tunnel: {
sourcePort: number | string;
endpointHost?: string | null;
endpointPort?: number | string | null;
},
): string {
return buildTunnelName(
host.id,
index,
tunnelHostLabel(host),
tunnel.sourcePort,
(tunnel.endpointHost ?? "").trim(),
tunnel.endpointPort ?? 0,
);
}
/** A desktop client tunnel's name, as the Electron main process tracks it. */
export function clientTunnelName(
tunnel: {
sourceHostId?: number;
mode?: string;
tunnelType?: string;
localAddress?: string;
remoteAddress?: string;
sourcePort: number;
endpointPort?: number;
},
index: number,
): string {
const address = (value?: string) => value?.trim() || "127.0.0.1";
return [
"c2s",
index,
tunnel.sourceHostId || 0,
tunnel.mode || tunnel.tunnelType || "local",
address(tunnel.localAddress),
address(tunnel.remoteAddress),
tunnel.sourcePort,
tunnel.endpointPort || 0,
].join("::");
}
+88
View File
@@ -0,0 +1,88 @@
export type TunnelScope = "s2s" | "c2s";
export type TunnelMode = "local" | "remote" | "dynamic";
export const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting",
} as const;
export type ConnectionState =
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
export type TunnelErrorType =
| "CONNECTION_FAILED"
| "AUTHENTICATION_FAILED"
| "TIMEOUT"
| "NETWORK_ERROR"
| "UNKNOWN";
/** One saved tunnel, as a host's tunnelConnections setting or a C2S preset holds it. */
export interface TunnelConnection {
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
localAddress?: string;
remoteAddress?: string;
bindHost?: string;
sourceHostId?: number;
sourceHostSyncId?: string;
sourceHostName?: string;
sourcePort: number;
endpointPort: number;
endpointHost?: string;
targetHost?: string;
/** Seconds. */
retryInterval: number;
maxRetries: number;
autoStart: boolean;
displayName?: string;
}
/** What POST /connect takes. Credentials are always resolved server side. */
export interface TunnelConnectRequest {
name: string;
sourceHostId: number;
tunnelIndex: number;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote";
bindHost?: string;
targetHost?: string;
endpointHost?: string;
sourcePort: number;
endpointPort: number;
maxRetries?: number;
/** Seconds. */
retryInterval?: number;
autoStart?: boolean;
}
export interface TunnelStatus {
connected: boolean;
status: ConnectionState;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
reason?: string;
errorType?: TunnelErrorType;
manualDisconnect?: boolean;
retryExhausted?: boolean;
}
export interface C2STunnelPreset {
id: number;
userId: string;
name: string;
config: TunnelConnection[];
platform?: string | null;
computerName?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import {
host,
manifest,
pluginDir,
startServer,
withHosts,
type TestServer,
} from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
let mock: MockPluginContext | null = null;
afterEach(async () => {
for (const dispose of [...(mock?.disposals ?? [])].reverse()) await dispose();
mock = null;
db?.close();
db = null;
await server?.close();
server = null;
});
async function activateWithout(capability: string, actor?: string) {
db = await createTestDb(pluginDir);
mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
actor,
});
return mock;
}
describe("tunnels activate", () => {
it("provides tunnels.access and serves the client relay socket", async () => {
server = await startServer();
const service = server.mock.services.get("tunnels.access") as Record<
string,
unknown
>;
for (const method of ["forward", "start", "stop", "status", "list"]) {
expect(typeof service[method]).toBe("function");
}
expect(server.mock.wsRoutes).toMatchObject([
{ path: "/c2s/stream", raw: false },
]);
});
for (const capability of ["db:own", "network:serve"]) {
it(`fails closed without ${capability}`, async () => {
const ctx = await activateWithout(capability);
await expect(activate(ctx.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
}
for (const capability of ["ssh:connect", "credentials:use", "hosts:read"]) {
it(`cannot open a tunnel without ${capability}`, async () => {
const ctx = await activateWithout(capability, "user-1");
// Host lookups stay gated when hosts:read is the one missing.
if (capability !== "hosts:read") withHosts(ctx, { "user-1": [host()] });
await activate(ctx.ctx);
const service = ctx.services.get("tunnels.access") as {
forward: (id: number, target: unknown) => Promise<unknown>;
};
await expect(
service.forward(7, { targetHost: "127.0.0.1", targetPort: 80 }),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
}
});
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { authorizeTunnelAction } from "../../src/backend/authorize.js";
/**
* /disconnect and /cancel are addressed by tunnel name. On-demand tunnels are
* never registered as configs, so a check that only ran for a registered
* config never ran for them, and `web:{hostId}:{endpointId}` is guessable.
* That let any user force-close another user's live tunnel.
*/
describe("authorizeTunnelAction", () => {
it("checks the host encoded in a reserved name when no config exists", async () => {
const canAccess = vi.fn().mockResolvedValue(true);
const result = await authorizeTunnelAction(
canAccess,
"web:7:e1",
undefined,
);
expect(result).toEqual({ allowed: true, hostId: 7 });
expect(canAccess).toHaveBeenCalledWith(7);
});
it("denies a reserved name whose host the user cannot access", async () => {
const canAccess = vi.fn().mockResolvedValue(false);
expect(
(await authorizeTunnelAction(canAccess, "web:7:e1", undefined)).allowed,
).toBe(false);
});
it("fails closed on a reserved name it cannot parse", async () => {
const canAccess = vi.fn().mockResolvedValue(true);
for (const name of ["web:", "web:abc:e1", "web:0:e1"]) {
expect(
(await authorizeTunnelAction(canAccess, name, undefined)).allowed,
).toBe(false);
}
expect(canAccess).not.toHaveBeenCalled();
});
it("still checks a registered config's host id", async () => {
const canAccess = vi.fn().mockResolvedValue(true);
await authorizeTunnelAction(canAccess, "my-tunnel", { sourceHostId: 42 });
expect(canAccess).toHaveBeenCalledWith(42);
});
it("leaves an ordinary unregistered name allowed", async () => {
const canAccess = vi.fn();
expect(
(await authorizeTunnelAction(canAccess, "my-tunnel", undefined)).allowed,
).toBe(true);
expect(canAccess).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { createTunnelManager } from "../../src/backend/manager.js";
import { startAutoStartTunnels } from "../../src/backend/autostart.js";
import { host, manifest, withHosts } from "./helpers";
class FakeClient extends EventEmitter {
forwardOut = vi.fn((_a, _b, _c, _d, cb) => cb(undefined, new PassThrough()));
end = vi.fn();
}
let manager: ReturnType<typeof createTunnelManager> | null = null;
afterEach(() => {
manager?.dispose();
manager = null;
});
const tunnel = (autoStart: boolean, sourcePort: number) => ({
scope: "s2s",
mode: "local",
sourcePort,
endpointHost: "127.0.0.1",
endpointPort: 9,
maxRetries: 3,
retryInterval: 5,
autoStart,
});
describe("autostart", () => {
it("starts only autostart tunnels on enabled hosts, as each host's owner", async () => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
sshClient: new FakeClient(),
});
withHosts(mock, {
"user-1": [host()],
"user-2": [host({ id: 8, userId: "user-2", name: "db" })],
});
await mock.ctx.settings.setHost(7, "enableTunnel", true);
await mock.ctx.settings.setHost(7, "tunnelConnections", [
tunnel(true, 0),
tunnel(false, 0),
]);
await mock.ctx.settings.setHost(8, "enableTunnel", false);
await mock.ctx.settings.setHost(8, "tunnelConnections", [tunnel(true, 0)]);
manager = createTunnelManager(mock.ctx);
const started = await startAutoStartTunnels(mock.ctx, manager, async () => [
{ id: 7, userId: "user-1" },
{ id: 8, userId: "user-2" },
]);
expect(started).toBe(1);
expect([...manager.configs.keys()]).toEqual(["7::0::web::0::127.0.0.1::9"]);
expect(
manager.configs.get("7::0::web::0::127.0.0.1::9")?.requestingUserId,
).toBe("user-1");
});
it("does nothing when the host list cannot be read", async () => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
});
manager = createTunnelManager(mock.ctx);
await expect(
startAutoStartTunnels(mock.ctx, manager, async () => {
throw new Error("no database");
}),
).resolves.toBe(0);
});
});
@@ -0,0 +1,155 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import type {
PluginContext,
PluginSshConnectOptions,
} from "@termix/plugin-sdk/backend";
import { createC2SPrompt } from "../../src/backend/c2s-auth.js";
import { createC2SConnections } from "../../src/backend/c2s-connections.js";
class Socket extends EventEmitter {
readyState = 1;
send = vi.fn();
close() {
this.readyState = 3;
this.emit("close");
}
get ws() {
return this as unknown as WebSocket;
}
answer(requestId: string, answer: string | null) {
this.emit(
"message",
Buffer.from(JSON.stringify({ type: "auth-response", requestId, answer })),
false,
);
}
}
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
afterEach(() => vi.useRealTimers());
describe("client tunnel authentication", () => {
it("accepts only the current challenge on its socket and removes answer listeners", async () => {
const socket = new Socket();
const prompt = createC2SPrompt(socket.ws);
const result = prompt.ask({ kind: "totp", prompt: "Code:", retry: false });
const sent = JSON.parse(socket.send.mock.calls[0][0]);
socket.answer("stale", "wrong");
expect(socket.listenerCount("message")).toBe(1);
socket.answer(sent.requestId, "123456");
await expect(result).resolves.toBe("123456");
expect(socket.listenerCount("message")).toBe(0);
expect(socket.send).toHaveBeenCalledTimes(1);
const retry = prompt.ask({ kind: "totp", prompt: "Code:", retry: true });
socket.answer(sent.requestId, "old");
socket.close();
await expect(retry).rejects.toThrow("cancelled");
});
it("cancels on a null answer and bounds unanswered prompts", async () => {
vi.useFakeTimers();
const cancelled = new Socket();
const request = createC2SPrompt(cancelled.ws).ask({
kind: "input",
prompt: "Password:",
echo: false,
isPush: false,
});
cancelled.answer(
JSON.parse(cancelled.send.mock.calls[0][0]).requestId,
null,
);
await expect(request).rejects.toThrow("cancelled");
expect(cancelled.readyState).toBe(3);
const socket = new Socket();
const result = createC2SPrompt(socket.ws).ask({
kind: "totp",
prompt: "Code:",
retry: false,
});
const failed = expect(result).rejects.toThrow("timed out");
await vi.advanceTimersByTimeAsync(60_000);
await failed;
expect(socket.readyState).toBe(3);
expect(socket.listenerCount("message")).toBe(0);
});
it("reuses one authenticated source for concurrent streams, scoped to user, host and desktop runtime", async () => {
let actor = "alice";
const disposals: Array<() => void> = [];
const connections: Array<{
client: EventEmitter;
dispose: ReturnType<typeof vi.fn>;
}> = [];
const connect = vi.fn(
async (_id: number, options: PluginSshConnectOptions) => {
expect(options.prompt).toBeDefined();
const result = {
client: new EventEmitter(),
host: {},
jumpClient: null,
dispose: vi.fn(),
};
connections.push(result);
return result;
},
);
const acquire = createC2SConnections({
currentActor: () => actor,
disposables: { add: (fn: () => void) => disposals.push(fn) },
ssh: { connect },
} as unknown as PluginContext);
const anchor = new Socket();
const first = new Socket();
const second = new Socket();
await Promise.all([
acquire(anchor.ws, 1, "runtime-a"),
acquire(first.ws, 1, "runtime-a"),
acquire(second.ws, 1, "runtime-a"),
]);
expect(connect).toHaveBeenCalledTimes(1);
first.close();
second.close();
await flush();
expect(connections[0].dispose).not.toHaveBeenCalled();
await acquire(new Socket().ws, 1, "runtime-b");
await acquire(new Socket().ws, 2, "runtime-a");
actor = "bob";
await acquire(new Socket().ws, 1, "runtime-a");
expect(connect).toHaveBeenCalledTimes(4);
anchor.close();
await flush();
expect(connections[0].dispose).toHaveBeenCalledOnce();
disposals.forEach((dispose) => dispose());
await flush();
expect(
connections.every((entry) => entry.dispose.mock.calls.length === 1),
).toBe(true);
});
it("disposes a connection that authenticates after the desktop stopped", async () => {
let ready!: (value: unknown) => void;
const source = { client: new EventEmitter(), dispose: vi.fn() };
const acquire = createC2SConnections({
currentActor: () => "alice",
disposables: { add: vi.fn() },
ssh: {
connect: () =>
new Promise((resolve) => {
ready = resolve;
}),
},
} as unknown as PluginContext);
const socket = new Socket();
const pending = acquire(socket.ws, 1, "stopped");
socket.close();
ready(source);
await expect(pending).rejects.toThrow("closed");
await flush();
expect(source.dispose).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,108 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import type { WebSocket } from "ws";
import type { PluginSshConnectOptions } from "@termix/plugin-sdk/backend";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { createC2SRelay } from "../../src/backend/c2s-relay.js";
import { host, manifest } from "./helpers.js";
class Socket extends EventEmitter {
readyState = 1;
send = vi.fn();
close() {
this.readyState = 3;
this.emit("close");
}
message(value: unknown) {
this.emit("message", Buffer.from(JSON.stringify(value)), false);
}
get ws() {
return this as unknown as WebSocket;
}
messages() {
return this.send.mock.calls.map(([data]) => JSON.parse(String(data)));
}
}
describe("interactive client relay", () => {
it("authenticates preflight once and forwards subsequent SOCKS streams on the retained connection", async () => {
const mock = createMockCtx({
pluginId: "tunnels",
manifest,
capabilities: manifest.capabilities,
permissions: ["tunnels.use"],
hosts: [host()],
});
const stream = new PassThrough();
const client = Object.assign(new EventEmitter(), {
forwardOut: vi.fn((_ip, _port, _target, _targetPort, callback) =>
callback(null, stream),
),
});
const dispose = vi.fn();
const connect = vi.fn(
async (_id: number, options: PluginSshConnectOptions) => {
expect(
await options.prompt!.ask({
kind: "totp",
prompt: "Verification code:",
retry: false,
}),
).toBe("123456");
return { client, dispose, host: host(), jumpClient: null };
},
);
mock.ctx.ssh.connect = connect as unknown as typeof mock.ctx.ssh.connect;
const handle = createC2SRelay(mock.ctx);
const anchor = new Socket();
const socket = new Socket();
const tunnelConfig = {
sourceHostId: 7,
mode: "dynamic",
sessionId: "desktop-runtime",
};
try {
handle(anchor.ws, "user-1");
anchor.message({ type: "test", keepAlive: true, tunnelConfig });
await vi.waitFor(() =>
expect(anchor.messages()[0]?.type).toBe("auth-prompt"),
);
anchor.message({
type: "auth-response",
requestId: anchor.messages()[0].requestId,
answer: "123456",
});
await vi.waitFor(() =>
expect(anchor.messages().at(-1)?.type).toBe("ready"),
);
expect(dispose).not.toHaveBeenCalled();
handle(socket.ws, "user-1");
socket.message({
type: "open",
tunnelConfig,
targetHost: "internal.example",
targetPort: 443,
});
await vi.waitFor(() =>
expect(socket.messages().at(-1)?.type).toBe("ready"),
);
expect(connect).toHaveBeenCalledOnce();
expect(client.forwardOut).toHaveBeenCalledWith(
"127.0.0.1",
0,
"internal.example",
443,
expect.any(Function),
);
socket.close();
expect(dispose).not.toHaveBeenCalled();
anchor.close();
await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce());
} finally {
socket.close();
anchor.close();
stream.destroy();
for (const cleanup of mock.disposals) await cleanup();
}
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { resolveC2SSourceHostId } from "../../src/backend/c2s-relay.js";
describe("resolveC2SSourceHostId", () => {
it("uses the row matching the stable sync id", async () => {
const findBySyncId = vi.fn().mockResolvedValue(42);
await expect(
resolveC2SSourceHostId(
{ sourceHostId: 7, sourceHostSyncId: "host-sync-id" },
findBySyncId,
),
).resolves.toBe(42);
expect(findBySyncId).toHaveBeenCalledWith("host-sync-id");
});
it("keeps a local id when no sync id is available", async () => {
await expect(
resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()),
).resolves.toBe(7);
});
it("does not silently fall back to a mismatched id", async () => {
await expect(
resolveC2SSourceHostId(
{ sourceHostId: 7, sourceHostSyncId: "missing" },
vi.fn().mockResolvedValue(null),
),
).rejects.toThrow("not found on the remote server");
});
});
+148
View File
@@ -0,0 +1,148 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type {
PluginContext,
PluginHostAccess,
PluginHostSummary,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export function host(
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id: 7,
userId: "user-1",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
/**
* Hosts each user can reach, standing in for ctx.hosts. createMockCtx has no
* per-user host list, and tunnels checks access as the acting user.
*/
export function withHosts(
mock: MockPluginContext,
byUser: Record<string, PluginHostSummary[]>,
): void {
const reachable = () => byUser[mock.ctx.currentActor() ?? ""] ?? [];
const hosts = mock.ctx.hosts as PluginContext["hosts"] & {
[key: string]: unknown;
};
Object.assign(hosts, {
list: async () => reachable(),
get: async (id: number) => reachable().find((h) => h.id === id) ?? null,
checkAccess: async (id: number): Promise<PluginHostAccess> => {
const found = reachable().find((h) => h.id === id);
return found
? {
hasAccess: true,
isOwner: found.userId === mock.ctx.currentActor(),
isShared: false,
permissionLevel: "manage",
}
: { hasAccess: false, isOwner: false, isShared: false };
},
});
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: Record<string, PluginHostSummary[]>;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["tunnels.use"],
});
withHosts(mock, options.hosts ?? { "user-1": [host()] });
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
for (const dispose of [...mock.disposals].reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,258 @@
import net from "node:net";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import { createTunnelManager } from "../../src/backend/manager.js";
import type { TunnelConfig } from "../../src/backend/types.js";
import { host, manifest, pluginDir, withHosts } from "./helpers";
/** Just enough of an ssh2 Client for the manager's channel calls. */
class FakeClient extends EventEmitter {
channels: PassThrough[] = [];
forwardOut = vi.fn(
(
_srcIP: string,
_srcPort: number,
_host: string,
_port: number,
cb: (err: Error | undefined, stream?: PassThrough) => void,
) => {
const channel = new PassThrough();
this.channels.push(channel);
cb(undefined, channel);
},
);
forwardIn = vi.fn(
(_host: string, port: number, cb: (err?: Error, port?: number) => void) =>
cb(undefined, port || 40000),
);
unforwardIn = vi.fn((_host: string, _port: number, cb?: () => void) =>
cb?.(),
);
end = vi.fn(() => this.emit("close"));
}
let manager: ReturnType<typeof createTunnelManager> | null = null;
afterEach(() => {
manager?.dispose();
manager = null;
});
function setup(client = new FakeClient()) {
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
actor: "user-1",
sshClient: client,
});
withHosts(mock, {
"user-1": [host(), host({ id: 9, name: "db", ip: "10.0.0.9" })],
});
manager = createTunnelManager(mock.ctx);
return { mock, client, manager };
}
function config(overrides: Partial<TunnelConfig> = {}): TunnelConfig {
return {
name: "7::0::web::0::127.0.0.1::9",
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.1",
sourceHostId: 7,
tunnelIndex: 0,
requestingUserId: "user-1",
hostName: "web",
sourceIP: "10.0.0.5",
sourceSSHPort: 22,
sourceUsername: "root",
endpointHost: "127.0.0.1",
sourcePort: 0,
endpointPort: 9,
maxRetries: 3,
retryInterval: 5000,
autoStart: false,
...overrides,
};
}
describe("both SSH legs go through ctx.ssh", () => {
it("never builds its own ssh2 Client", () => {
// The host key check lives in core's connect pipeline. A raw
// `new Client()` here would skip it, which is the bug this plugin fixed.
const dir = join(pluginDir, "src", "backend");
for (const file of readdirSync(dir)) {
const source = readFileSync(join(dir, file), "utf8");
expect(source, file).not.toMatch(/new Client\(/);
expect(source, file).not.toMatch(
/import \{[^}]*\bClient\b[^}]*\} from "ssh2"/,
);
}
});
it("opens the endpoint leg over a channel on the source connection", async () => {
const { mock, client } = setup();
const runtime = await manager!.connect(
config({
name: "7::0::web::8080::db::5432",
mode: "remote",
tunnelType: "remote",
endpointHost: "db",
endpointHostId: 9,
endpointIP: "10.0.0.9",
endpointSSHPort: 22,
sourcePort: 8080,
endpointPort: 5432,
}),
0,
{ throwOnError: true },
);
expect(runtime).not.toBeNull();
expect(mock.sshConnections).toHaveLength(2);
const [source, endpoint] = mock.sshConnections;
expect(source.host).toBe(7);
expect(source.options?.purpose).toBe("tunnel");
expect(source.options?.sock).toBeUndefined();
expect(client.forwardOut.mock.calls[0].slice(2, 4)).toEqual([
"10.0.0.9",
22,
]);
expect(endpoint.host).toBe(9);
expect(endpoint.options?.purpose).toBe("tunnel");
expect(endpoint.options?.sock).toBe(client.channels[0]);
// Remote mode binds on the endpoint leg.
expect(client.forwardIn).toHaveBeenCalledWith(
"127.0.0.1",
5432,
expect.any(Function),
);
});
});
describe("local listeners", () => {
it("reports the kernel-assigned port, never the requested 0", async () => {
setup();
const runtime = await manager!.connect(config(), 0, {
throwOnError: true,
});
expect(runtime!.bindPort).toBeGreaterThan(0);
await new Promise<void>((resolve, reject) => {
const socket = net.connect(runtime!.bindPort, "127.0.0.1", () => {
socket.destroy();
resolve();
});
socket.once("error", reject);
});
expect(manager!.statuses.get(config().name)?.status).toBe("connected");
});
it("honours a fixed source port", async () => {
setup();
const probe = net.createServer();
const wanted = await new Promise<number>((resolve) => {
probe.listen(0, "127.0.0.1", () =>
resolve((probe.address() as { port: number }).port),
);
});
await new Promise<void>((resolve) => probe.close(() => resolve()));
const runtime = await manager!.connect(
config({ name: "web:7:fixed", sourcePort: wanted }),
0,
{ throwOnError: true },
);
expect(runtime!.bindPort).toBe(wanted);
});
it("closes an idle tunnel and drops its runtime entry", async () => {
setup();
await manager!.connect(
config({ name: "web:7:idle", idleTimeoutMs: 60 }),
0,
{ throwOnError: true },
);
expect(manager!.runtimes.has("web:7:idle")).toBe(true);
await vi.waitFor(
() => expect(manager!.runtimes.has("web:7:idle")).toBe(false),
{ timeout: 4000, interval: 25 },
);
});
it("leaves a tunnel with no idle timeout running", async () => {
setup();
await manager!.connect(config({ name: "web:7:forever" }), 0, {
throwOnError: true,
});
await new Promise((resolve) => setTimeout(resolve, 300));
expect(manager!.runtimes.has("web:7:forever")).toBe(true);
});
});
describe("failures and stops", () => {
it("reports an auth failure without retrying and tells automations", async () => {
const { mock } = setup();
const connect = vi
.spyOn(mock.ctx.ssh, "connect")
.mockRejectedValue(
new Error("All configured authentication methods failed"),
);
await manager!.connect(config());
const status = manager!.statuses.get(config().name);
expect(status?.status).toBe("failed");
expect(status?.errorType).toBe("AUTHENTICATION_FAILED");
expect(connect).toHaveBeenCalledTimes(1);
expect(mock.emitted).toContainEqual({
topic: "plugin.tunnels.tunnel_disconnected",
payload: {
userId: "user-1",
hostId: 7,
tunnelName: config().name,
},
});
});
it("stops on request and holds off an immediate reconnect", async () => {
const { mock } = setup();
manager!.configs.set(config().name, config());
await manager!.connect(config(), 0, { throwOnError: true });
await manager!.stop(config().name);
expect(manager!.runtimes.has(config().name)).toBe(false);
expect(manager!.statuses.get(config().name)).toMatchObject({
status: "disconnected",
manualDisconnect: true,
});
expect(await manager!.connect(config())).toBeNull();
expect(
mock.emitted.some(
(event) => event.topic === "plugin.tunnels.tunnel_disconnected",
),
).toBe(false);
});
it("only shows a user the tunnels on hosts they can reach", async () => {
setup();
manager!.configs.set(config().name, config());
await manager!.connect(config(), 0, { throwOnError: true });
expect(Object.keys(await manager!.statusesFor("user-1"))).toEqual([
config().name,
]);
expect(await manager!.statusesFor("user-2")).toEqual({});
});
});
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// c2s_tunnel_presets as core's SQLite bootstrap created it before 2.9.0.
// Core never indexed it.
const LEGACY_DDL = `
CREATE TABLE c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting c2s_tunnel_presets", () => {
it("keeps every existing row when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO c2s_tunnel_presets (user_id, name, config, platform, computer_name) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", "Laptop", '[{"scope":"c2s"}]', "win32", "laptop");
sqlite
.prepare(
"INSERT INTO c2s_tunnel_presets (user_id, name, config) VALUES (?, ?, ?)",
)
.run("user-2", "Desk", "[]");
},
});
expect(db.applied).toEqual(["0001_adopt_c2s_tunnel_presets"]);
expect(tableExists("c2s_tunnel_presets")).toBe(false);
expect(tableExists("p_tunnels_presets")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT user_id, name, config, platform, computer_name FROM p_tunnels_presets ORDER BY id",
)
.all(),
).toEqual([
{
user_id: "user-1",
name: "Laptop",
config: '[{"scope":"c2s"}]',
platform: "win32",
computer_name: "laptop",
},
{
user_id: "user-2",
name: "Desk",
config: "[]",
platform: null,
computer_name: null,
},
]);
});
it("creates the table fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("c2s_tunnel_presets")).toBe(false);
expect(tableExists("p_tunnels_presets")).toBe(true);
expect(db.sqlite.prepare("SELECT * FROM p_tunnels_presets").all()).toEqual(
[],
);
});
it("removes a user's presets when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_tunnels_presets (user_id, name, config) VALUES (?, ?, '[]')",
);
insert.run("user-1", "Mine");
insert.run("user-2", "Theirs");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
expect(
(
db.sqlite.prepare("SELECT name FROM p_tunnels_presets").all() as {
name: string;
}[]
).map((row) => row.name),
).toEqual(["Theirs"]);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
RESERVED_TUNNEL_NAME_PREFIX,
buildWebEndpointTunnelName,
isReservedTunnelName,
parseReservedTunnelName,
} from "../../src/backend/utils.js";
describe("reserved tunnel names", () => {
it("builds a name from a host id and endpoint id", () => {
expect(buildWebEndpointTunnelName(7, "e1")).toBe("web:7:e1");
});
it("recognises its own names and nothing else", () => {
expect(isReservedTunnelName(buildWebEndpointTunnelName(7, "e1"))).toBe(
true,
);
expect(isReservedTunnelName("my-tunnel")).toBe(false);
expect(isReservedTunnelName("website")).toBe(false);
expect(isReservedTunnelName(RESERVED_TUNNEL_NAME_PREFIX)).toBe(true);
});
it("round-trips through parse", () => {
expect(
parseReservedTunnelName(buildWebEndpointTunnelName(7, "e1")),
).toEqual({
hostId: 7,
endpointId: "e1",
});
});
it("keeps an endpoint id containing colons intact", () => {
// Endpoint ids are client-supplied strings with no character restriction,
// so splitting on the LAST colon would corrupt them. The host id segment
// is digits only, so splitting on the first colon after it is safe.
expect(parseReservedTunnelName("web:7:a:b:c")).toEqual({
hostId: 7,
endpointId: "a:b:c",
});
});
it("returns null for anything it cannot verify", () => {
// Callers use this for an ownership check, so null must mean "cannot
// verify" and they must fail closed. Never return a partial guess.
for (const name of [
"my-tunnel",
"web:",
"web:7",
"web::e1",
"web:abc:e1",
"web:-1:e1",
"web:0:e1",
"web:7:",
"web:7.5:e1",
]) {
expect(parseReservedTunnelName(name)).toBeNull();
}
});
});
@@ -0,0 +1,220 @@
import { afterEach, describe, expect, it } from "vitest";
import { host, startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const clientTunnel = (overrides: Record<string, unknown> = {}) => ({
scope: "c2s",
mode: "local",
sourcePort: 8080,
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: false,
...overrides,
});
describe("client tunnel presets", () => {
it("creates, lists by name, updates and deletes the caller's presets", async () => {
server = await startServer();
const zulu = await server.request("POST", "/presets", {
body: {
name: " Zulu ",
config: [clientTunnel()],
platform: "linux",
computerName: "workstation",
},
});
expect(zulu.status).toBe(201);
expect(zulu.body).toMatchObject({
name: "Zulu",
config: [clientTunnel()],
platform: "linux",
computerName: "workstation",
});
await server.request("POST", "/presets", {
body: { name: "Alpha", config: [] },
});
await server.request("POST", "/presets", {
user: "user-2",
body: { name: "Other", config: [] },
});
const listed = await server.request("GET", "/presets");
expect(listed.body.map((p: { name: string }) => p.name)).toEqual([
"Alpha",
"Zulu",
]);
const updated = await server.request("PUT", `/presets/${zulu.body.id}`, {
body: { name: "Yankee", config: [clientTunnel({ mode: "dynamic" })] },
});
expect(updated.status).toBe(200);
expect(updated.body.name).toBe("Yankee");
expect(updated.body.config[0].mode).toBe("dynamic");
const deleted = await server.request("DELETE", `/presets/${zulu.body.id}`);
expect(deleted.body).toEqual({ success: true });
expect(
(await server.request("GET", "/presets")).body.map(
(p: { name: string }) => p.name,
),
).toEqual(["Alpha"]);
expect(server.db.persisted).toBeGreaterThan(0);
});
it("rejects a duplicate name and a config that is not a client tunnel", async () => {
server = await startServer();
await server.request("POST", "/presets", {
body: { name: "Home", config: [] },
});
expect(
(
await server.request("POST", "/presets", {
body: { name: "Home", config: [] },
})
).status,
).toBe(409);
for (const config of [
[clientTunnel({ scope: "s2s" })],
[clientTunnel({ mode: "sideways" })],
[clientTunnel({ sourcePort: 70000 })],
[clientTunnel({ endpointPort: 0 })],
"nope",
]) {
const res = await server.request("POST", "/presets", {
body: { name: "Bad", config },
});
expect(res.status).toBe(400);
}
// Dynamic mode has no destination port to check.
expect(
(
await server.request("POST", "/presets", {
body: {
name: "Socks",
config: [clientTunnel({ mode: "dynamic", endpointPort: 0 })],
},
})
).status,
).toBe(201);
});
it("never lets one user touch another user's preset", async () => {
server = await startServer();
const mine = await server.request("POST", "/presets", {
body: { name: "Mine", config: [] },
});
for (const [method, body] of [
["PUT", { name: "Stolen" }],
["DELETE", undefined],
] as const) {
const res = await server.request(method, `/presets/${mine.body.id}`, {
user: "user-2",
body,
});
expect(res.status).toBe(404);
}
expect((await server.request("GET", "/presets")).body[0].name).toBe("Mine");
});
});
describe("tunnel control", () => {
it("refuses reserved names and a name that does not match its config", async () => {
server = await startServer();
const reserved = await server.request("POST", "/connect", {
body: { name: "web:7:e1", sourceHostId: 7, tunnelIndex: 0 },
});
expect(reserved.status).toBe(400);
expect(reserved.body.error).toMatch(/reserved/);
const mismatched = await server.request("POST", "/connect", {
body: {
name: "7::0::web::8080::db::5432",
sourceHostId: 7,
tunnelIndex: 0,
sourcePort: 9999,
endpointHost: "db",
endpointPort: 5432,
},
});
expect(mismatched.status).toBe(400);
});
it("refuses to connect through a host the caller cannot reach", async () => {
server = await startServer({ hosts: { "user-1": [host()] } });
const res = await server.request("POST", "/connect", {
user: "user-2",
body: {
name: "7::0::web::8080::db::5432",
sourceHostId: 7,
tunnelIndex: 0,
sourcePort: 8080,
endpointHost: "db",
endpointPort: 5432,
},
});
expect(res.status).toBe(403);
expect(server.mock.sshConnections).toEqual([]);
});
it("denies stopping someone else's on-demand tunnel by guessing its name", async () => {
server = await startServer({
hosts: { "user-1": [host()], "user-2": [] },
});
for (const path of ["/disconnect", "/cancel"]) {
const res = await server.request("POST", path, {
user: "user-2",
body: { tunnelName: "web:7:e1" },
});
expect(res.status).toBe(403);
}
const own = await server.request("POST", "/disconnect", {
body: { tunnelName: "web:7:e1" },
});
expect(own.status).toBe(200);
});
it("answers an empty status map and a 404 for a tunnel nobody can see", async () => {
server = await startServer();
expect((await server.request("GET", "/status")).body).toEqual({});
expect((await server.request("GET", "/status/nope")).status).toBe(404);
});
});
describe("permissions", () => {
it("answers 403 on every route without tunnels.use", async () => {
server = await startServer({ permissions: [] });
const routes: Array<[string, string, unknown?]> = [
["GET", "/status"],
["GET", "/status/stream"],
["GET", "/status/some-tunnel"],
["POST", "/connect", { name: "x", sourceHostId: 7, tunnelIndex: 0 }],
["POST", "/disconnect", { tunnelName: "x" }],
["POST", "/cancel", { tunnelName: "x" }],
["GET", "/presets"],
["POST", "/presets", { name: "x", config: [] }],
["PUT", "/presets/1", { name: "x" }],
["DELETE", "/presets/1"],
];
for (const [method, path, body] of routes) {
const res = await server.request(method, path, { body });
expect(res.status, `${method} ${path}`).toBe(403);
}
});
});
@@ -0,0 +1,175 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import { createTunnelManager } from "../../src/backend/manager.js";
import {
createTunnelsService,
type TunnelsAccess,
} from "../../src/backend/service.js";
import { host, manifest, withHosts } from "./helpers";
class FakeClient extends EventEmitter {
failForward = false;
forwardOut = vi.fn(
(
_srcIP: string,
_srcPort: number,
_host: string,
_port: number,
cb: (err: Error | undefined, stream?: PassThrough) => void,
) => {
if (this.failForward)
cb(new Error("Channel open failure: connect failed"));
else cb(undefined, new PassThrough());
},
);
forwardIn = vi.fn();
unforwardIn = vi.fn();
end = vi.fn(() => this.emit("close"));
}
let manager: ReturnType<typeof createTunnelManager> | null = null;
afterEach(() => {
manager?.dispose();
manager = null;
});
function setup(): {
mock: MockPluginContext;
client: FakeClient;
service: TunnelsAccess;
} {
const client = new FakeClient();
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
actor: "user-1",
sshClient: client,
});
withHosts(mock, { "user-1": [host()], "user-2": [] });
manager = createTunnelManager(mock.ctx);
return { mock, client, service: createTunnelsService(mock.ctx, manager) };
}
const target = { targetHost: "127.0.0.1", targetPort: 8006 };
describe("tunnels.access forward()", () => {
it("resolves with the bound port once the target answered a probe", async () => {
const { service, client } = setup();
const handle = await service.forward(7, target, { name: "web:7:e1" });
expect(handle.bindHost).toBe("127.0.0.1");
expect(handle.bindPort).toBeGreaterThan(0);
expect(client.forwardOut).toHaveBeenCalledWith(
"127.0.0.1",
0,
"127.0.0.1",
8006,
expect.any(Function),
);
await handle.close();
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
});
it("reuses a live forward and reopens one whose target changed", async () => {
const { service, mock } = setup();
const first = await service.forward(7, target, { name: "web:7:e1" });
const again = await service.forward(7, target, { name: "web:7:e1" });
expect(again.bindPort).toBe(first.bindPort);
expect(mock.sshConnections).toHaveLength(1);
await service.forward(
7,
{ ...target, targetPort: 9000 },
{ name: "web:7:e1" },
);
expect(mock.sshConnections).toHaveLength(2);
});
it("rejects with the real reason and leaves nothing behind when the target is closed", async () => {
const { service, client } = setup();
client.failForward = true;
await expect(
service.forward(7, target, { name: "web:7:e1" }),
).rejects.toThrow(/connect failed/);
expect(manager!.runtimes.has("web:7:e1")).toBe(false);
});
it("refuses a host the caller cannot reach and a name that is not reserved", async () => {
const { service, mock } = setup();
await mock.ctx.asUser("user-2", async () => {
await expect(service.forward(7, target)).rejects.toThrow(/access denied/);
});
await expect(
service.forward(7, target, { name: "my-tunnel" }),
).rejects.toThrow(/Forward names/);
await expect(
service.forward(7, target, { name: "web:8:e1" }),
).rejects.toThrow(/Forward names/);
expect(mock.sshConnections).toEqual([]);
});
});
describe("tunnels.access start/stop/status", () => {
const saved = {
scope: "s2s",
mode: "local",
sourcePort: 0,
endpointHost: "127.0.0.1",
endpointPort: 9,
maxRetries: 3,
retryInterval: 5,
autoStart: false,
};
const name = "7::0::web::0::127.0.0.1::9";
it("starts a host's saved tunnel by name and stops it again", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", true);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await service.start(name);
await vi.waitFor(async () =>
expect((await service.status(name))?.status).toBe("connected"),
);
expect(Object.keys(await service.list())).toEqual([name]);
await service.stop(name);
expect((await service.status(name))?.status).toBe("disconnected");
});
it("refuses a name that matches no saved tunnel", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", false);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await expect(service.start(name)).rejects.toThrow(/not configured/);
await expect(service.start("nope")).rejects.toThrow(/not configured/);
});
it("hides a tunnel from a user who cannot reach its host", async () => {
const { service, mock } = setup();
await mock.ctx.settings.setHost(7, "enableTunnel", true);
await mock.ctx.settings.setHost(7, "tunnelConnections", [saved]);
await service.start(name);
// The fake context tracks one actor globally, so let the connect settle
// before switching users.
await vi.waitFor(async () =>
expect((await service.status(name))?.status).toBe("connected"),
);
await mock.ctx.asUser("user-2", async () => {
expect(await service.status(name)).toBeNull();
expect(await service.list()).toEqual({});
await expect(service.stop(name)).rejects.toThrow(/Access denied/);
});
});
});
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
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;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
const savedTunnel = {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "db",
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: false,
};
const webHost = {
id: "7",
name: "web",
ip: "10.0.0.5",
port: 22,
username: "root",
enableSsh: true,
pluginSettings: {
tunnels: { enableTunnel: true, tunnelConnections: [savedTunnel] },
},
};
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
return {
get: vi.fn(async () => ({ data: {} })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
...overrides,
} as PluginApiClient;
}
describe(`${manifest.id} activate`, () => {
it("registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const panels = [...declared(contributes.panels), ...tabs];
expect(rendered.registered.tabs()).toEqual(["tunnel"]);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
expect(rendered.registered.hostEditorSections()).toEqual(["tunnels"]);
expect(rendered.registered.hostActions()).toEqual([
expect.objectContaining({ id: "tunnel", tabType: "tunnel" }),
]);
expect(rendered.registered.actions().sort()).toEqual(["tunnels.open"]);
expect(rendered.registered.slot("dashboard.counters")).toEqual([
"tunnels.open",
]);
const [rail] = rendered.registered.railItems();
expect(rail).toMatchObject({
id: "port-forwarding",
permission: "tunnels.use",
});
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
describe("tunnel tab", () => {
it("lists a host's saved tunnels and starts one by name through the plugin api", async () => {
const api = stubApi();
rendered = await renderWithApp(plugin, {
manifest,
locales,
api,
hosts: [webHost],
});
rendered.renderTab("tunnel", { host: webHost });
expect(await screen.findByText("db:5432")).toBeTruthy();
// "tunnels.start" is still a core string, reached through the fallback.
fireEvent.click(screen.getByText(/start/i));
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
"/connect",
expect.objectContaining({
name: "7::0::web::8080::db::5432",
sourceHostId: 7,
tunnelIndex: 0,
endpointHost: "db",
}),
),
);
});
});
describe("host editor section", () => {
it("writes the tunnel list into the form's tunnels plugin settings", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: stubApi(),
});
let form: Record<string, unknown> = {
name: "web",
pluginSettings: { tunnels: { enableTunnel: true } },
};
const updateForm = vi.fn(
(
patch: (current: Record<string, unknown>) => Record<string, unknown>,
) => {
form = patch(form);
},
);
rendered.renderHostEditorSection("tunnels", {
form,
setField: vi.fn(),
updateForm,
protocols: { ssh: true },
});
fireEvent.click(screen.getByText(locales.hosts.addTunnelBtn));
const settings = (form.pluginSettings as Record<string, unknown>)
.tunnels as { enableTunnel: boolean; tunnelConnections: unknown[] };
expect(settings.enableTunnel).toBe(true);
expect(settings.tunnelConnections).toHaveLength(1);
expect(settings.tunnelConnections[0]).toMatchObject({
scope: "s2s",
mode: "local",
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
mergeTunnelStatuses,
parseServerSentEvents,
setTunnelsApi,
subscribeTunnelStatuses,
} from "../../src/frontend/api";
afterEach(() => {
setTunnelsApi(null);
});
describe("mergeTunnelStatuses", () => {
it("keeps local state when a remote tunnel with the same name differs", () => {
// Controls always target the local backend, so its view wins.
expect(
mergeTunnelStatuses(
{ shared: { connected: true, status: "connected" } },
{
shared: { connected: false, status: "disconnected" },
remoteOnly: { connected: true, status: "connected" },
},
),
).toEqual({
shared: { connected: true, status: "connected" },
remoteOnly: { connected: true, status: "connected" },
});
});
});
describe("parseServerSentEvents", () => {
it("splits complete events and hands back a partial one", () => {
const events: Array<{ event: string; data: string }> = [];
const rest = parseServerSentEvents(
': keepalive\n\nevent: statuses\ndata: {"a":1}\n\nevent: stat',
(event) => events.push(event),
);
expect(events).toEqual([{ event: "statuses", data: '{"a":1}' }]);
expect(rest).toBe("event: stat");
});
});
function streamOf(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
},
});
return { ok: true, body } as unknown as Response;
}
describe("subscribeTunnelStatuses", () => {
it("streams statuses from the plugin's event stream and merges remote ones", async () => {
setTunnelsApi({
defaults: { baseURL: "http://localhost:30001" },
} as never);
const fetchImpl = vi.fn(async (_url: string) =>
streamOf([
`event: statuses\ndata: ${JSON.stringify({ shared: { connected: true, status: "connected" } })}\n\n`,
]),
);
const onStatuses = vi.fn();
const stop = subscribeTunnelStatuses(onStatuses, undefined, {
fetchImpl: fetchImpl as unknown as typeof fetch,
fetchRemote: async () => ({
shared: { connected: false, status: "disconnected" },
remoteOnly: { connected: true, status: "connected" },
}),
});
await vi.waitFor(() =>
expect(onStatuses).toHaveBeenLastCalledWith({
shared: { connected: true, status: "connected" },
remoteOnly: { connected: true, status: "connected" },
}),
);
expect(fetchImpl.mock.calls[0][0]).toBe(
"http://localhost:30001/plugin-api/tunnels/status/stream",
);
stop();
});
it("falls back to polling the status route when there is no stream url", async () => {
const get = vi.fn(async () => ({
data: { t: { connected: true, status: "connected" } },
}));
setTunnelsApi({ get } as never);
const onStatuses = vi.fn();
const stop = subscribeTunnelStatuses(onStatuses);
await vi.waitFor(() =>
expect(onStatuses).toHaveBeenCalledWith({
t: { connected: true, status: "connected" },
}),
);
expect(get).toHaveBeenCalledWith("/status");
stop();
});
});
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
let rendered: RenderedPluginApp | null = null;
const original = window.electronAPI;
afterEach(async () => {
await act(async () => {
await rendered?.deactivate();
});
rendered = null;
window.electronAPI = original;
});
describe("tunnel prompt overlay", () => {
it("queues challenges, clears the previous code, and cancels on plugin disposal", async () => {
type Event = Parameters<
Parameters<typeof window.electronAPI.onC2SAuthPrompt>[0]
>[0];
let receive!: (event: Event) => void;
const answer = vi.fn().mockResolvedValue(true);
const unsubscribe = vi.fn();
window.electronAPI = {
...original,
answerC2SAuth: answer,
onC2SAuthPrompt: (fn) => {
receive = fn;
return unsubscribe;
},
};
rendered = await renderWithApp(plugin, {
manifest: manifestJson as unknown as PluginManifest,
locales,
});
rendered.renderSlot("shell.overlay");
act(() => {
receive({
id: "one",
tunnelName: "SOCKS",
request: { kind: "totp", prompt: "Code:", retry: false },
});
receive({
id: "two",
tunnelName: "Other",
request: { kind: "totp", prompt: "Code:", retry: true },
});
});
const input = screen.getByLabelText("Authentication response");
expect(input.getAttribute("type")).toBe("password");
fireEvent.change(input, { target: { value: "123456" } });
fireEvent.click(screen.getByText("Continue"));
expect(answer).toHaveBeenCalledWith("one", "123456");
expect(
(screen.getByLabelText("Authentication response") as HTMLInputElement)
.value,
).toBe("");
expect(screen.getByRole("alert").textContent).toContain("previous code");
act(() => receive({ id: "two", closed: true }));
expect(screen.queryByRole("dialog")).toBeNull();
act(() =>
receive({
id: "three",
request: {
kind: "input",
prompt: "Password:",
echo: false,
isPush: false,
},
}),
);
await act(async () => {
await rendered!.deactivate();
});
rendered = null;
expect(answer).toHaveBeenCalledWith("three", null);
expect(unsubscribe).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import {
normalizeClientTunnel,
stripClientTunnelDiagnostics,
type ClientTunnel,
} from "../../src/frontend/c2s-tunnel-config-utils";
describe("C2STunnelPresetManager tunnel normalization", () => {
it("preserves explicit local and remote address fields in saved config", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: " 127.0.0.2 ",
remoteAddress: " 10.10.0.25 ",
sourceHostId: 42,
sourceHostSyncId: "host-sync-42",
sourcePort: 8080,
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: true,
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
localAddress: "127.0.0.2",
remoteAddress: "10.10.0.25",
bindHost: "127.0.0.2",
targetHost: "10.10.0.25",
sourceHostId: 42,
sourceHostSyncId: "host-sync-42",
sourcePort: 8080,
endpointPort: 5432,
});
});
it("preserves source host sync identity for cross-device C2S presets", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.25",
sourceHostId: 5,
sourceHostSyncId: "shared-host-sync-id",
sourcePort: 8080,
endpointPort: 80,
maxRetries: 3,
retryInterval: 10,
autoStart: false,
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
sourceHostId: 5,
sourceHostSyncId: "shared-host-sync-id",
});
});
it("normalizes legacy bindHost and targetHost configs into address fields", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.3",
targetHost: "172.16.1.50",
sourceHostId: 7,
sourcePort: 9000,
endpointPort: 9443,
maxRetries: 1,
retryInterval: 5,
autoStart: false,
});
expect(tunnel).toMatchObject({
localAddress: "127.0.0.3",
remoteAddress: "172.16.1.50",
bindHost: "127.0.0.3",
targetHost: "172.16.1.50",
});
});
it("preserves tunnel name spacing while editing", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
displayName: "Axolot ",
});
expect(tunnel.displayName).toBe("Axolot ");
});
it("trims tunnel names only when saving", () => {
const tunnel = normalizeClientTunnel({
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
sourcePort: 8080,
endpointPort: 80,
displayName: " Axolot MO ",
});
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).toMatchObject({
displayName: "Axolot MO",
});
});
it("removes diagnostics without dropping routing fields", () => {
const tunnel: ClientTunnel = {
scope: "c2s",
mode: "local",
tunnelType: "local",
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
bindHost: "127.0.0.1",
targetHost: "10.0.0.10",
sourceHostId: 1,
sourcePort: 8080,
endpointPort: 80,
endpointHost: "edge",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
displayName: "web",
lastStartedAt: "2026-07-20T00:00:00.000Z",
lastTestedAt: "2026-07-20T00:01:00.000Z",
lastError: "previous error",
};
const saved = stripClientTunnelDiagnostics(tunnel);
expect(saved).not.toHaveProperty("lastStartedAt");
expect(saved).not.toHaveProperty("lastTestedAt");
expect(saved).not.toHaveProperty("lastError");
expect(saved).toMatchObject({
localAddress: "127.0.0.1",
remoteAddress: "10.0.0.10",
sourceHostId: 1,
sourcePort: 8080,
endpointPort: 80,
});
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import {
getTunnelTypeForMode,
getTunnelPortLabels,
getTunnelModeDescription,
} from "../../src/frontend/tunnel-form-utils";
// A fake translate that echoes the key plus any interpolation args so we can
// assert which translation key + payload the helpers chose.
const t = (key: string, opts?: Record<string, string | number>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key;
describe("getTunnelTypeForMode", () => {
it("maps remote to remote and everything else to local", () => {
expect(getTunnelTypeForMode("remote")).toBe("remote");
expect(getTunnelTypeForMode("local")).toBe("local");
expect(getTunnelTypeForMode("dynamic")).toBe("local");
});
});
describe("getTunnelPortLabels", () => {
it("labels client local mode source/endpoint ports", () => {
const labels = getTunnelPortLabels("client", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
expect(labels.endpointPortLabel).toBe("tunnels.destinationPort");
});
it("swaps labels for client remote mode", () => {
const labels = getTunnelPortLabels("client", "remote", t);
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
});
it("uses host/endpoint labels in server scope", () => {
const labels = getTunnelPortLabels("server", "local", t);
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
});
});
describe("getTunnelModeDescription", () => {
const ports = { sourcePort: 8080, endpointPort: 9090 };
it("selects the client dynamic description with only the source port", () => {
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
expect(desc).toContain("8080");
expect(desc).not.toContain("9090");
});
it("selects the client local description with both ports", () => {
const desc = getTunnelModeDescription("client", "local", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
expect(desc).toContain("8080");
expect(desc).toContain("9090");
});
it("selects the server remote description", () => {
const desc = getTunnelModeDescription("server", "remote", ports, t);
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
clientTunnelName,
serverTunnelName,
} from "../../src/shared/tunnel-naming";
import { connectRequestFor } from "../../src/frontend/host-tunnels";
const host = { id: "7", name: "web", username: "root", ip: "10.0.0.5" };
const tunnel = {
scope: "s2s" as const,
mode: "local" as const,
sourcePort: 8080,
endpointHost: " db ",
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: false,
};
describe("tunnel names", () => {
it("builds a server tunnel name the connect request carries unchanged", () => {
const name = serverTunnelName(host, 0, tunnel);
expect(name).toBe("7::0::web::8080::db::5432");
const request = connectRequestFor(host, 0, tunnel);
expect(request.name).toBe(name);
// The backend checks the name against these fields, so they must agree.
expect(request.endpointHost).toBe("db");
expect(request.sourceHostId).toBe(7);
});
it("falls back to user@ip for an unnamed host", () => {
expect(serverTunnelName({ ...host, name: "" }, 1, tunnel)).toBe(
"7::1::root@10.0.0.5::8080::db::5432",
);
});
it("names a client tunnel the way the desktop app tracks it", () => {
expect(
clientTunnelName(
{
sourceHostId: 3,
mode: "remote",
localAddress: "",
remoteAddress: "10.0.0.1",
sourcePort: 9000,
endpointPort: 80,
},
2,
),
).toBe("c2s::2::3::remote::127.0.0.1::10.0.0.1::9000::80");
});
});
+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);