feat: convert ssh tunnels into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 17:27:28 -05:00
1 parent c85c1fdb86
commit 1682861853
220 files changed
+36928 -11374

No files matched your search

+7 -1
View File
@@ -28,7 +28,8 @@
],
"optionalDependencies": {
"snippets": "^1.0.0",
"fleets": "^1.0.0"
"fleets": "^1.0.0",
"tunnels": "^1.0.0"
},
"requires": [
{
@@ -40,6 +41,11 @@
"service": "fleets.access",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "tunnels.access",
"versionRange": "^1.0.0",
"optional": true
}
],
"contributes": {
@@ -10,6 +10,7 @@ import {
import { withSshConnection } from "../ssh.js";
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
import { runTunnelAction } from "../tunnels.js";
import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.js";
import { renderRecord, renderTemplate } from "../template.js";
@@ -241,25 +242,15 @@ async function runTunnel(
const name = renderTemplate(step.tunnelName, context.template);
if (context.dryRun) return ok(`Would ${step.action} tunnel ${name}`);
try {
const manager =
await import("../../../../../src/backend/hosts/tunnel/manager.js");
const config = manager.tunnelConfigs?.get(name);
if (!config) return fail(`Tunnel "${name}" is not configured`);
if (step.action === "connect") {
await manager.connectSSHTunnel(config);
return ok(`Tunnel ${name} connected`);
}
// shouldRetry false, otherwise the manager immediately reconnects the
// tunnel the automation just asked it to drop.
manager.manualDisconnects.add(name);
await manager.handleDisconnect(name, config, false);
return ok(`Tunnel ${name} disconnected`);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
// A disconnect goes through the tunnels plugin's manual stop, which also
// holds off its retries, so the tunnel stays down.
const result = await runTunnelAction(context.userId, step.action, name);
if (!result.ok) return fail(result.error);
return ok(
step.action === "connect"
? `Tunnel ${name} connected`
: `Tunnel ${name} disconnected`,
);
}
async function runWol(
+31
View File
@@ -1,17 +1,48 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./snippets.js";
import { setTunnelServices } from "./tunnels.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js";
import {
startAutomationScheduler,
stopAutomationScheduler,
} from "./scheduler.js";
import { onInternalEvent } from "./triggers.js";
/** What the tunnels plugin emits when a tunnel drops without being asked to. */
interface TunnelDisconnectedEvent {
userId?: string;
hostId?: number;
tunnelName?: string;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
setTunnelServices(ctx.services);
ctx.disposables.add(() => setTunnelServices(null));
// Nothing arrives here while the tunnels plugin is off, which is the whole
// of the optional dependency.
ctx.events.on("plugin.tunnels.tunnel_disconnected", (payload) => {
const event = payload as TunnelDisconnectedEvent;
if (!event?.userId) return;
void onInternalEvent({
event: "tunnel_disconnected",
userId: event.userId,
hostId: event.hostId,
details: { tunnelName: event.tunnelName },
}).catch((error: unknown) => {
ctx.log.warn(
`Tunnel disconnect trigger failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
});
startAutomationsService(
// The webhook route authenticates on its own per-automation token rather
// than a session, which is the point of an inbound webhook.
@@ -0,0 +1,63 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setTunnelServices(services: PluginServices | null): void {
current = services;
}
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
start: (name: string) => Promise<void>;
stop: (name: string) => Promise<void>;
}
export interface TunnelActionResult {
ok: boolean;
/** True when the tunnels plugin is off or the user may not use it. */
unavailable?: boolean;
error?: string;
}
/**
* Connects or disconnects a saved tunnel as userId. Optional: without the
* tunnels plugin the step fails with a clear message rather than the plugin
* failing to activate.
*/
export async function runTunnelAction(
userId: string,
action: "connect" | "disconnect",
name: string,
): Promise<TunnelActionResult> {
if (!current) {
return {
ok: false,
unavailable: true,
error: "The tunnels plugin is not available",
};
}
let tunnels: TunnelsAccess;
try {
tunnels = current.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
return {
ok: false,
unavailable: true,
error: "The tunnels plugin is not available",
};
}
try {
if (action === "connect") await tunnels.start(name);
else await tunnels.stop(name);
return { ok: true };
} catch (error) {
return {
ok: false,
unavailable: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentNotificationChannelRepository: vi.fn(),
}));
const { setTunnelServices } = await import("../../src/backend/tunnels.js");
const { executeStep } = await import("../../src/backend/actions/index.js");
afterEach(() => {
setTunnelServices(null);
});
function context(dryRun = false) {
return {
userId: "user-1",
automationId: 1,
runId: 1,
dryRun,
template: {},
} as never;
}
const step = (action: "connect" | "disconnect") =>
({
id: "s1",
type: "tunnel",
action,
tunnelName: "7::0::web::8080::db::5432",
}) as never;
describe("the tunnel step", () => {
it("starts and stops the tunnel through tunnels.access as the automation's owner", async () => {
const fake = createFakeContext({ pluginId: "automations" });
const tunnels = {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
};
fake.ctx.services.provide("tunnels.access", tunnels);
const get = vi.spyOn(fake.ctx.services, "get");
setTunnelServices(fake.ctx.services);
const connected = await executeStep(step("connect"), context());
expect(connected.success).toBe(true);
expect(tunnels.start).toHaveBeenCalledWith("7::0::web::8080::db::5432");
expect(get).toHaveBeenCalledWith("tunnels.access", { userId: "user-1" });
const stopped = await executeStep(step("disconnect"), context());
expect(stopped.success).toBe(true);
expect(tunnels.stop).toHaveBeenCalledWith("7::0::web::8080::db::5432");
});
it("reports the tunnel plugin's own error", async () => {
const fake = createFakeContext({ pluginId: "automations" });
fake.ctx.services.provide("tunnels.access", {
start: vi.fn(async () => {
throw new Error('Tunnel "x" is not configured');
}),
stop: vi.fn(),
});
setTunnelServices(fake.ctx.services);
const result = await executeStep(step("connect"), context());
expect(result.success).toBe(false);
expect(result.error ?? result.output).toMatch(/not configured/);
});
it("fails the step cleanly while the tunnels plugin is off", async () => {
// No provider: the optional dependency is missing.
setTunnelServices(
createFakeContext({ pluginId: "automations" }).ctx.services,
);
const withoutProvider = await executeStep(step("connect"), context());
expect(withoutProvider.success).toBe(false);
expect(withoutProvider.error ?? withoutProvider.output).toMatch(
/tunnels plugin is not available/,
);
setTunnelServices(null);
const inactive = await executeStep(step("disconnect"), context());
expect(inactive.success).toBe(false);
});
it("only describes what it would do on a dry run", async () => {
const result = await executeStep(step("connect"), context(true));
expect(result.success).toBe(true);
expect(result.output).toMatch(/Would connect tunnel/);
});
});
+18
View File
@@ -0,0 +1,18 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Tunnels moved out of core's own server on port 30003 and now serve under
`/plugin-api/tunnels` and `/plugin-ws/tunnels`.
- Both SSH legs of a tunnel now go through core's connect pipeline, so a host
with an unknown or changed host key is refused the same way every other
connection refuses it.
- Owns its data: core's `c2s_tunnel_presets` table is renamed to
`p_tunnels_presets` on first activation, and each host's tunnel switch and
saved tunnels move into this plugin's host settings.
- New `tunnels.use` permission, given to the admin and user roles.
- Offers `tunnels.access` to other plugins, used by web-endpoint and
automations. A tunnel that drops on its own emits
`plugin.tunnels.tunnel_disconnected`.
+44
View File
@@ -0,0 +1,44 @@
# Tunnels
SSH port forwarding between servers, and client tunnels from the desktop app.
Bundled with Termix and enabled by default. It imports only
`@termix/plugin-sdk`.
## What it does
- **Server tunnels**: local, remote and dynamic (SOCKS5) forwards saved on a
host, started from the Tunnels tab, the host editor or at boot when marked
auto-start. A tunnel either rides the source host's connection alone, or
opens a second SSH leg to a Termix endpoint host through a channel on the
source connection.
- **Client tunnels**: the desktop app listens locally and relays each
connection over `/plugin-ws/tunnels/c2s/stream`; presets of that list are
saved per user.
- **`tunnels.access`**: a service other plugins call. `forward()` opens an
on-demand forward and resolves once it works (web-endpoint uses it);
`start`/`stop`/`status`/`list` work on saved tunnels by name (automations).
Both SSH legs go through `ctx.ssh.connect`, so tunnels get host key checks,
auth providers, proxies and jump hosts like every other transport. The
endpoint leg passes its forwardOut channel as `sock`.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx), the tunnel manager, routes, relay, service
src/shared/ types and tunnel naming used by both bundles
migrations/ one .sql per dialect, generated from src/backend/tables.ts
src/frontend/ the tab, host editor section, rail panel and widget
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Data
- `p_tunnels_presets`, adopted from core's `c2s_tunnel_presets`.
- Host settings `enableTunnel` and `tunnelConnections`, copied from the
`ssh_data` columns of the same names by core's
`tunnels-settings-migration.ts`.
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
+159
View File
@@ -0,0 +1,159 @@
{
"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"
},
"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."
},
"permissions": {
"use": {
"title": "Use tunnels",
"description": "Start, stop and watch SSH tunnels, and save client tunnel presets."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable tunnels"
}
}
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "نوع النفق"
},
"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": "إعدادات النفق المُسبَقة للعميل متاحة فقط في تطبيق سطح المكتب."
},
"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": "جارٍ العمل..."
}
}
@@ -0,0 +1,139 @@
{
"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": "Тип тунел"
},
"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": "Предварителните настройки на клиентските тунели са налични само в настолния клиент."
},
"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": "Работа..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "টানেল যোগ করুন",
"autoStartDesc": "হোস্ট লোড হলে এই টানেলটি স্বয়ংক্রিয়ভাবে সংযুক্ত করুন।",
"autoStartLabel": "স্বয়ংক্রিয়-শুরু",
"connectBtn": "সংযোগ করুন",
"disconnectBtn": "সংযোগ বিচ্ছিন্ন করুন",
"enableTunneling": "টানেলিং সক্ষম করুন",
"enableTunnelingDesc": "এই হোস্টের জন্য SSH টানেল কার্যকারিতা সক্রিয় করুন",
"endpointHost": "এন্ডপয়েন্ট হোস্ট",
"endpointHostPlaceholder": "একই হোস্ট, SSH হোস্ট, অথবা পৌঁছানো যায় এমন হোস্ট/আইপি",
"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": "টানেল টাইপ"
},
"nav": {
"portForwarding": "ফরোয়ার্ডিং",
"tunnels": "টানেল"
},
"placeholders": {
"defaultEndpointPort": "২২৪",
"defaultPort": "২২",
"localListenerHost": "১২৭.০.০.১ (স্থানীয়ভাবে শুনুন)",
"localTargetHost": "১২৭.০.০.১ (এই কম্পিউটারে লক্ষ্যবস্তু)",
"maxRetries": "৩",
"remoteBindHost": "১২৭.০.০.১ (এন্ডপয়েন্টে শুনুন)",
"remoteTargetHost": "১২৭.০.০.১ (এন্ডপয়েন্ট থেকে টার্গেট)",
"retryInterval": "১০",
"socksListenerHost": "১২৭.০.০.১ (SOCKS লিসেনার)"
},
"profile": {
"c2sCurrentLocalConfig": "এই ডেস্কটপে {{count}} টি স্থানীয় ক্লায়েন্ট টানেল কনফিগার করা হয়েছে।",
"c2sLoadPreset": "লোড",
"c2sNoPresetSelected": "কোনো পূর্বনির্ধারিত সেট নির্বাচন করা হয়নি",
"c2sNoPresets": "কোন প্রিসেট সংরক্ষণ করা হয়নি",
"c2sPresetDeleted": "ক্লায়েন্ট টানেল প্রিসেট মুছে ফেলা হয়েছে",
"c2sPresetLoadError": "ক্লায়েন্ট টানেল প্রিসেট লোড করতে ব্যর্থ হয়েছে",
"c2sPresetLoaded": "ক্লায়েন্ট টানেল প্রিসেট স্থানীয়ভাবে লোড করা হয়েছে",
"c2sPresetName": "পূর্বনির্ধারিত নাম",
"c2sPresetNamePlaceholder": "ক্লায়েন্টের পূর্বনির্ধারিত নাম",
"c2sPresetRenamed": "ক্লায়েন্ট টানেল প্রিসেটের নাম পরিবর্তন করা হয়েছে",
"c2sPresetSaved": "ক্লায়েন্ট টানেল প্রিসেট সংরক্ষিত",
"c2sPresetSyncNote": "প্রিসেটগুলি হলো সুনির্দিষ্ট স্ন্যাপশট; এর কোনো একটি লোড করলে এই ডেস্কটপ ক্লায়েন্টের স্থানীয় ক্লায়েন্ট টানেল তালিকা প্রতিস্থাপিত হয়।",
"c2sPresetToLoad": "লোড করার জন্য প্রিসেট",
"c2sTunnelPresets": "ক্লায়েন্ট টানেল প্রিসেট",
"c2sTunnelPresetsUnavailable": "ক্লায়েন্ট টানেল প্রিসেটগুলো শুধুমাত্র ডেস্কটপ ক্লায়েন্টে পাওয়া যায়।"
},
"tunnels": {
"attempt": "{{max}} এর {{current}} প্রচেষ্টা",
"autoNameClientDynamic": "মোজা {{localPort}} {{endpoint}} এর মাধ্যমে",
"autoNameClientLocal": "স্থানীয় {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → স্থানীয় {{localPort}}",
"autoStart": "অটো স্টার্ট",
"cancel": "বাতিল করুন",
"canceling": "বাতিল করা হচ্ছে...",
"clientAutoStartDesc": "এই ডেস্কটপ ক্লায়েন্টটি খুললে এটি চালু হয় এবং সংযুক্ত থাকে।",
"clientManualStartDesc": "এই সারি থেকে স্টার্ট এবং স্টপ ব্যবহার করুন। টারমিক্স এটি স্বয়ংক্রিয়ভাবে খুলবে না।",
"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": "বর্তমান হোস্টে SOCKS {{sourcePort}}।",
"forwardDescriptionServerLocal": "বর্তমান হোস্ট {{sourcePort}} → শেষ বিন্দু {{endpointPort}}.",
"forwardDescriptionServerRemote": "এন্ডপয়েন্ট {{endpointPort}} → বর্তমান হোস্ট {{sourcePort}}.",
"host": "হোস্ট",
"intermediateHost": "মধ্যবর্তী হোস্ট",
"invalidBindIp": "লোকাল আইপি অবশ্যই একটি বৈধ IPv4 অ্যাড্রেস হতে হবে।",
"invalidEndpointPort": "এন্ডপয়েন্ট পোর্ট অবশ্যই ১ থেকে ৬৫৫৩৫-এর মধ্যে হতে হবে।",
"invalidLocalPort": "স্থানীয় পোর্ট অবশ্যই ১ থেকে ৬৫৫৩৫-এর মধ্যে হতে হবে।",
"invalidLocalTargetIp": "স্থানীয় টার্গেট আইপি অবশ্যই একটি বৈধ IPv4 অ্যাড্রেস হতে হবে।",
"invalidLocalTargetPort": "স্থানীয় টার্গেট পোর্ট অবশ্যই ১ থেকে ৬৫৫৩৫-এর মধ্যে হতে হবে।",
"invalidRemoteAddress": "দূরবর্তী ঠিকানা প্রয়োজন।",
"invalidRemotePort": "রিমোট পোর্ট অবশ্যই ১ থেকে ৬৫৫৩৫-এর মধ্যে হতে হবে।",
"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": "কাজ চলছে..."
}
}
@@ -0,0 +1,139 @@
{
"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": "Túnel que connecta...",
"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"
},
"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."
},
"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": "Túnel del client iniciat",
"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 del 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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "Koncový port",
"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"
},
"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 (posluchač 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."
},
"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": "Koncový port",
"endpointSshHost": "Koncový SSH hostitel",
"endpointSshHostPlaceholder": "Vyberte nakonfigurovaného hostitele",
"endpointSshHostRequired": "Pro každý klientský tunel vyberte koncový bod SSH hostitele.",
"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": "Přístav",
"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í..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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 endpoint SSH-vært 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..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "Τύπος σήραγγας"
},
"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": "Οι προεπιλογές σήραγγας προγράμματος-πελάτη είναι διαθέσιμες μόνο στον υπολογιστή-πελάτη για υπολογιστές."
},
"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": "Εργαζόμενος..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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 esiasetettu nimi",
"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."
},
"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äätepisteisä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": "Uudelleenyritysten enimmäismäärä",
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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…"
}
}
@@ -0,0 +1,139 @@
{
"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": "סוג המנהרה"
},
"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": "הגדרות קבועות מראש של מנהרת לקוח זמינות רק בלקוח שולחן העבודה."
},
"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": "עובד..."
}
}
@@ -0,0 +1,139 @@
{
"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": "टनल प्रकार"
},
"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}} स्थानीय क्लाइंट टनल(s) कॉन्फ़िगर की गई हैं।",
"c2sLoadPreset": "लोड करें",
"c2sNoPresetSelected": "कोई प्रीसेट चयनित नहीं",
"c2sNoPresets": "कोई प्रीसेट सहेजा नहीं गया",
"c2sPresetDeleted": "क्लाइंट टनल प्रीसेट हटाया गया",
"c2sPresetLoadError": "क्लाइंट टनल प्रीसेट लोड करने में विफल",
"c2sPresetLoaded": "क्लाइंट टनल प्रीसेट स्थानीय रूप से लोड हुआ",
"c2sPresetName": "प्रीसेट नाम",
"c2sPresetNamePlaceholder": "क्लाइंट प्रीसेट नाम",
"c2sPresetRenamed": "क्लाइंट टनल प्रीसेट का नाम बदला गया",
"c2sPresetSaved": "क्लाइंट टनल प्रीसेट सहेजा गया",
"c2sPresetSyncNote": "प्रीसेट स्पष्ट स्नैपशॉट हैं; एक लोड करने पर यह डेस्कटॉप क्लाइंट की स्थानीय क्लाइंट टनल सूची को बदल देता है।",
"c2sPresetToLoad": "लोड करने के लिए प्रीसेट",
"c2sTunnelPresets": "क्लाइंट टनल प्रीसेट",
"c2sTunnelPresetsUnavailable": "क्लाइंट टनल प्रीसेट केवल डेस्कटॉप क्लाइंट में उपलब्ध हैं।"
},
"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}} का उपयोग कर सकती है।",
"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": "स्थानीय आईपी एक मान्य IPv4 पता होना चाहिए।",
"invalidEndpointPort": "एंडपॉइंट पोर्ट 1 और 65535 के बीच होना चाहिए।",
"invalidLocalPort": "स्थानीय पोर्ट 1 और 65535 के बीच होना चाहिए।",
"invalidLocalTargetIp": "स्थानीय लक्ष्य आईपी एक मान्य 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}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "परीक्षण करें",
"tunnelName": "टनल का नाम",
"tunnelTestFailed": "टनल परीक्षण विफल",
"tunnelTestSucceeded": "टनल परीक्षण सफल",
"type": "टनल प्रकार",
"typeDynamic": "डायनामिक (-D)",
"typeLocal": "स्थानीय (-L)",
"typeRemote": "दूरस्थ (-R)",
"working": "कार्यरत..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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ó..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "Tambahkan Terowongan",
"autoStartDesc": "Terowongan ini akan terhubung secara otomatis saat host dimuat.",
"autoStartLabel": "Mulai otomatis",
"connectBtn": "Menghubungkan",
"disconnectBtn": "Memutuskan",
"enableTunneling": "Aktifkan Tunneling",
"enableTunnelingDesc": "Aktifkan fungsi terowongan SSH untuk host ini.",
"endpointHost": "Host Titik Akhir",
"endpointHostPlaceholder": "Host yang sama, host SSH, atau host/IP yang dapat dijangkau",
"endpointPort": "Port Titik Akhir",
"failedToConnectTunnel": "Gagal terhubung",
"failedToDisconnectTunnel": "Gagal memutuskan koneksi",
"maxRetries": "Jumlah Percobaan Maksimum",
"noTunnelsConfigured": "Tidak ada terowongan yang dikonfigurasi.",
"retryIntervalS": "Interval Percobaan Ulang (detik)",
"serverTunnelsSection": "Terowongan Server",
"sourcePort": "Port Sumber",
"tabTunnels": "Terowongan",
"tunnelConnecting": "Terowongan penghubung...",
"tunnelDisconnected": "Terowongan terputus",
"tunnelLabel": "Terowongan {{number}}",
"tunnelModeDynamicDesc": "Buat proxy SOCKS5 pada port lokal untuk penerusan port dinamis.",
"tunnelModeLocalDesc": "Teruskan port lokal ke port pada server jarak jauh (atau host yang dapat dijangkau dari server tersebut).",
"tunnelModeRemoteDesc": "Teruskan port pada server jarak jauh kembali ke port lokal pada mesin Anda.",
"tunnelRequirementsText": "Server SSH harus memiliki pengaturan GatewayPorts yes, AllowTcpForwarding yes, dan PermitRootLogin yes di /etc/ssh/sshd_config.",
"tunnelSettings": "Pengaturan Terowongan",
"tunnelType": "Jenis Terowongan"
},
"nav": {
"portForwarding": "Meneruskan",
"tunnels": "Terowongan"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (dengarkan secara lokal)",
"localTargetHost": "127.0.0.1 (target pada komputer ini)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (dengarkan di titik akhir)",
"remoteTargetHost": "127.0.0.1 (target dari titik akhir)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (Pendengar SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} terowongan klien lokal yang dikonfigurasi pada desktop ini.",
"c2sLoadPreset": "Memuat",
"c2sNoPresetSelected": "Tidak ada preset yang dipilih.",
"c2sNoPresets": "Tidak ada preset yang tersimpan.",
"c2sPresetDeleted": "Pengaturan awal terowongan klien telah dihapus.",
"c2sPresetLoadError": "Gagal memuat preset terowongan klien",
"c2sPresetLoaded": "Pengaturan terowongan klien dimuat secara lokal.",
"c2sPresetName": "Nama Preset",
"c2sPresetNamePlaceholder": "Nama preset klien",
"c2sPresetRenamed": "Pengaturan terowongan klien diubah namanya.",
"c2sPresetSaved": "Pengaturan terowongan klien tersimpan.",
"c2sPresetSyncNote": "Preset adalah snapshot eksplisit; memuat salah satunya akan mengganti daftar terowongan klien lokal klien desktop ini.",
"c2sPresetToLoad": "Preset untuk Memuat",
"c2sTunnelPresets": "Pengaturan Awal Terowongan Klien",
"c2sTunnelPresetsUnavailable": "Pengaturan terowongan klien hanya tersedia di klien desktop."
},
"tunnels": {
"attempt": "Percobaan {{current}} dari {{max}}",
"autoNameClientDynamic": "KAOS KAKI {{localPort}} melalui {{endpoint}}",
"autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}",
"autoStart": "Mulai Otomatis",
"cancel": "Membatalkan",
"canceling": "Membatalkan...",
"clientAutoStartDesc": "Proses dimulai saat klien desktop ini dibuka dan tetap terhubung.",
"clientManualStartDesc": "Gunakan tombol Mulai dan Berhenti dari baris ini. Termix tidak akan membukanya secara otomatis.",
"clientRemoteServerNote": "Penerusan jarak jauh mungkin memerlukan AllowTcpForwarding dan GatewayPorts pada server SSH titik akhir. Port jarak jauh akan tertutup ketika desktop ini terputus.",
"clientTunnelStarted": "Terowongan klien telah dimulai.",
"clientTunnelStopped": "Terowongan klien dihentikan",
"clientTunnels": "Terowongan Klien",
"connected": "Terhubung",
"connecting": "Menghubungkan...",
"createFirstTunnelMessage": "Anda belum membuat terowongan SSH apa pun. Konfigurasikan koneksi terowongan di Host Manager untuk memulai.",
"currentHostPort": "Port Host Saat Ini",
"destination": "Tujuan",
"destinationAddress": "Alamat Tujuan",
"destinationPort": "Pelabuhan Tujuan",
"disconnected": "Terputus",
"duplicateAutoStartBind": "Hanya satu terowongan klien auto-start yang dapat menggunakan {{bind}}.",
"endpointPort": "Port Titik Akhir",
"endpointSshHost": "Host SSH Titik Akhir",
"endpointSshHostPlaceholder": "Pilih host yang telah dikonfigurasi.",
"endpointSshHostRequired": "Pilih host SSH titik akhir untuk setiap terowongan klien.",
"error": "Kesalahan",
"forwardDescriptionClientDynamic": "SOCKS pada port lokal {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokal {{sourcePort}} → jarak jauh {{endpointPort}}.",
"forwardDescriptionClientRemote": "Jarak jauh {{sourcePort}} → lokal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS pada host saat ini {{sourcePort}}.",
"forwardDescriptionServerLocal": "Host saat ini {{sourcePort}} → titik akhir {{endpointPort}}.",
"forwardDescriptionServerRemote": "Titik akhir {{endpointPort}} → host saat ini {{sourcePort}}.",
"host": "Tuan rumah",
"intermediateHost": "Inang Perantara",
"invalidBindIp": "IP lokal harus berupa alamat IPv4 yang valid.",
"invalidEndpointPort": "Port endpoint harus berada di antara 1 dan 65535.",
"invalidLocalPort": "Port lokal harus berada di antara 1 dan 65535.",
"invalidLocalTargetIp": "Alamat IP target lokal harus berupa alamat IPv4 yang valid.",
"invalidLocalTargetPort": "Port target lokal harus berada di antara 1 dan 65535.",
"invalidRemoteAddress": "Alamat jarak jauh diperlukan.",
"invalidRemotePort": "Port jarak jauh harus berada di antara 1 dan 65535.",
"lastError": "Kesalahan terakhir",
"lastStarted": "Terakhir dimulai",
"lastTested": "Terakhir diuji",
"localAddress": "Alamat Lokal",
"localPort": "Pelabuhan Lokal",
"localSaveError": "Gagal menyimpan terowongan klien lokal",
"localSaved": "Terowongan klien tersimpan",
"manualControlError": "Gagal memperbarui status terowongan.",
"maxRetries": "Jumlah Percobaan Maksimum",
"mode": "Mode",
"nextRetryIn": "Percobaan berikutnya dalam {{seconds}} detik",
"noClientTunnels": "Tidak ada terowongan klien yang dikonfigurasi pada desktop ini.",
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
"noSshTunnels": "Tidak ada terowongan SSH",
"port": "Pelabuhan",
"remoteBindAddress": "Alamat Pengikatan Jarak Jauh",
"remoteHost": "Host Jarak Jauh",
"remotePort": "Port Jarak Jauh",
"retryInterval": "Interval Percobaan Ulang (detik)",
"route": "Rute:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → KAOS KAKI melalui {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} melalui {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} pada {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Tes",
"tunnelName": "Nama Terowongan",
"tunnelTestFailed": "Uji terowongan gagal",
"tunnelTestSucceeded": "Uji terowongan berhasil",
"type": "Jenis Terowongan",
"typeDynamic": "Dinamis (-D)",
"typeLocal": "Lokal (-L)",
"typeRemote": "Jarak Jauh (-R)",
"working": "Bekerja..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"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."
},
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "トンネルの種類"
},
"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": "クライアントトンネルプリセットはデスクトップクライアントでのみ利用可能です。"
},
"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": "処理中..."
}
}
@@ -0,0 +1,139 @@
{
"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": "터널 유형"
},
"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": "클라이언트 터널 프리셋은 데스크톱 클라이언트에서만 사용할 수 있습니다."
},
"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": "작업 중..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "Voeg tunnel toe",
"autoStartDesc": "Deze tunnel automatisch verbinden wanneer de host wordt geladen.",
"autoStartLabel": "Automatisch starten",
"connectBtn": "Verbinden",
"disconnectBtn": "Verbinding verbreken",
"enableTunneling": "Tunneling inschakelen",
"enableTunnelingDesc": "Schakel de SSH-tunnelfunctionaliteit voor deze host in.",
"endpointHost": "Eindpunthost",
"endpointHostPlaceholder": "Dezelfde host, SSH-host of bereikbare host/IP-adres",
"endpointPort": "Eindpuntpoort",
"failedToConnectTunnel": "Verbinding mislukt",
"failedToDisconnectTunnel": "Verbinding verbreken mislukt",
"maxRetries": "Max. aantal herhalingen",
"noTunnelsConfigured": "Er zijn geen tunnels geconfigureerd.",
"retryIntervalS": "Herhaalinterval (s)",
"serverTunnelsSection": "Servertunnels",
"sourcePort": "Bronpoort",
"tabTunnels": "Tunnels",
"tunnelConnecting": "Tunnelverbinding...",
"tunnelDisconnected": "Tunnel losgekoppeld",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Maak een SOCKS5-proxy aan op een lokale poort voor dynamische poortdoorsturing.",
"tunnelModeLocalDesc": "Een lokale poort doorsturen naar een poort op de externe server (of een host die vanaf die server bereikbaar is).",
"tunnelModeRemoteDesc": "Een poort op de externe server doorsturen naar een lokale poort op uw computer.",
"tunnelRequirementsText": "De SSH-server moet de volgende instellingen hebben: GatewayPorts yes, AllowTcpForwarding yes en PermitRootLogin yes in /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnelinstellingen",
"tunnelType": "Tunneltype"
},
"nav": {
"portForwarding": "Doorsturen",
"tunnels": "Tunnels"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lokaal luisteren)",
"localTargetHost": "127.0.0.1 (doel op deze computer)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (luisteren op eindpunt)",
"remoteTargetHost": "127.0.0.1 (doel van eindpunt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-luisteraar)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokale clienttunnel(s) geconfigureerd op deze desktop.",
"c2sLoadPreset": "Laden",
"c2sNoPresetSelected": "Geen voorinstelling geselecteerd",
"c2sNoPresets": "Geen presets opgeslagen",
"c2sPresetDeleted": "Client tunnel-voorinstelling verwijderd",
"c2sPresetLoadError": "Het laden van de clienttunnelvoorinstellingen is mislukt.",
"c2sPresetLoaded": "Client tunnel preset lokaal geladen",
"c2sPresetName": "Voorinstellingnaam",
"c2sPresetNamePlaceholder": "Naam van de clientvoorinstelling",
"c2sPresetRenamed": "Client tunnel-voorinstelling hernoemd",
"c2sPresetSaved": "Voorinstelling voor clienttunnel opgeslagen",
"c2sPresetSyncNote": "Voorinstellingen zijn expliciete momentopnamen; het laden ervan vervangt de lokale lijst met clienttunnels van deze desktopclient.",
"c2sPresetToLoad": "Voorinstelling om te laden",
"c2sTunnelPresets": "Clienttunnel-voorinstellingen",
"c2sTunnelPresetsUnavailable": "Clienttunnelvoorinstellingen zijn alleen beschikbaar in de desktopclient."
},
"tunnels": {
"attempt": "Poging {{current}} van {{max}}",
"autoNameClientDynamic": "SOKKEN {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokaal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokaal {{localPort}}",
"autoStart": "Automatische start",
"cancel": "Annuleren",
"canceling": "Annuleren...",
"clientAutoStartDesc": "Begint wanneer deze desktopclient wordt geopend en blijft verbonden.",
"clientManualStartDesc": "Gebruik Start en Stop vanuit deze regel. Termix opent deze niet automatisch.",
"clientRemoteServerNote": "Voor het doorsturen van externe poorten zijn mogelijk de instellingen AllowTcpForwarding en GatewayPorts vereist op de SSH-server van het eindpunt. De externe poort wordt gesloten wanneer deze desktop de verbinding verbreekt.",
"clientTunnelStarted": "Clienttunnel gestart",
"clientTunnelStopped": "Clienttunnel gestopt",
"clientTunnels": "Clienttunnels",
"connected": "Aangesloten",
"connecting": "Verbinden...",
"createFirstTunnelMessage": "Je hebt nog geen SSH-tunnels aangemaakt. Configureer tunnelverbindingen in de Host Manager om te beginnen.",
"currentHostPort": "Huidige hostpoort",
"destination": "Bestemming",
"destinationAddress": "Bestemmingsadres",
"destinationPort": "Bestemmingshaven",
"disconnected": "Verbinding verbroken",
"duplicateAutoStartBind": "Slechts één automatisch startende clienttunnel kan {{bind}} gebruiken.",
"endpointPort": "Eindpuntpoort",
"endpointSshHost": "Eindpunt SSH-host",
"endpointSshHostPlaceholder": "Selecteer een geconfigureerde host.",
"endpointSshHostRequired": "Selecteer een SSH-host voor elke clienttunnel.",
"error": "Fout",
"forwardDescriptionClientDynamic": "SOCKS op lokale poort {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokaal {{sourcePort}} → extern {{endpointPort}}.",
"forwardDescriptionClientRemote": "Extern {{sourcePort}} → lokaal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS op de huidige host {{sourcePort}}.",
"forwardDescriptionServerLocal": "Huidige host {{sourcePort}} → eindpunt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Eindpunt {{endpointPort}} → huidige host {{sourcePort}}.",
"host": "Gastheer",
"intermediateHost": "Tussenliggende gastheer",
"invalidBindIp": "Het lokale IP-adres moet een geldig IPv4-adres zijn.",
"invalidEndpointPort": "De eindpoort moet tussen 1 en 65535 liggen.",
"invalidLocalPort": "Het lokale poortnummer moet tussen 1 en 65535 liggen.",
"invalidLocalTargetIp": "Het lokale doel-IP-adres moet een geldig IPv4-adres zijn.",
"invalidLocalTargetPort": "De lokale doelpoort moet tussen 1 en 65535 liggen.",
"invalidRemoteAddress": "Een extern adres is vereist.",
"invalidRemotePort": "De externe poort moet tussen 1 en 65535 liggen.",
"lastError": "Laatste fout",
"lastStarted": "Laatst gestart",
"lastTested": "Laatst getest",
"localAddress": "Lokaal adres",
"localPort": "Lokale haven",
"localSaveError": "Het opslaan van lokale clienttunnels is mislukt.",
"localSaved": "Clienttunnels opgeslagen",
"manualControlError": "Het bijwerken van de tunnelstatus is mislukt.",
"maxRetries": "Max. aantal herhalingen",
"mode": "Modus",
"nextRetryIn": "Volgende poging over {{seconds}} seconden",
"noClientTunnels": "Er zijn geen clienttunnels geconfigureerd op deze desktop.",
"noHostSelected": "Geen host geselecteerd",
"noSshTunnels": "Geen SSH-tunnels",
"port": "Haven",
"remoteBindAddress": "Extern bindadres",
"remoteHost": "Externe host",
"remotePort": "Externe poort",
"retryInterval": "Interval voor herpogingen (seconden)",
"route": "Route:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOKKEN via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} op {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnelnaam",
"tunnelTestFailed": "Tunneltest mislukt",
"tunnelTestSucceeded": "Tunneltest geslaagd",
"type": "Tunneltype",
"typeDynamic": "Dynamisch (-D)",
"typeLocal": "Lokaal (-L)",
"typeRemote": "Afstandsbediening (-R)",
"working": "Bezig met verwerken..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "Legg til tunnel",
"autoStartDesc": "Koble til denne tunnelen automatisk når verten lastes inn",
"autoStartLabel": "Automatisk start",
"connectBtn": "Koble til",
"disconnectBtn": "Frakople",
"enableTunneling": "Aktiver tunnelering",
"enableTunnelingDesc": "Aktiver SSH-tunnelfunksjonalitet for denne verten",
"endpointHost": "Endepunktvert",
"endpointHostPlaceholder": "Samme vert, SSH-vert eller tilgjengelig vert/IP",
"endpointPort": "Endepunktport",
"failedToConnectTunnel": "Kunne ikke koble til",
"failedToDisconnectTunnel": "Kunne ikke koble fra",
"maxRetries": "Maks antall nye forsøk",
"noTunnelsConfigured": "Ingen tunneler konfigurert.",
"retryIntervalS": "Intervall for nye forsøk (s)",
"serverTunnelsSection": "Servertunneler",
"sourcePort": "Kildeport",
"tabTunnels": "Tunneler",
"tunnelConnecting": "Tunnelforbindelse...",
"tunnelDisconnected": "Tunnelen frakoblet",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Opprett en SOCKS5-proxy på en lokal port for dynamisk portvideresending.",
"tunnelModeLocalDesc": "Videresend en lokal port til en port på den eksterne serveren (eller en vert som er tilgjengelig fra den).",
"tunnelModeRemoteDesc": "Videresend en port på den eksterne serveren tilbake til en lokal port på maskinen din.",
"tunnelRequirementsText": "SSH-serveren må ha GatewayPorts ja, AllowTcpForwarding ja og PermitRootLogin ja angitt i /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnelinnstillinger",
"tunnelType": "Tunneltype"
},
"nav": {
"portForwarding": "Videresending",
"tunnels": "Tunneler"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lytt lokalt)",
"localTargetHost": "127.0.0.1 (mål på denne datamaskinen)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (lytt på endepunkt)",
"remoteTargetHost": "127.0.0.1 (mål fra endepunkt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-lytter)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokale klienttunneler konfigurert på dette skrivebordet.",
"c2sLoadPreset": "Laste",
"c2sNoPresetSelected": "Ingen forhåndsinnstilling valgt",
"c2sNoPresets": "Ingen forhåndsinnstillinger lagret",
"c2sPresetDeleted": "Forhåndsinnstilling for klienttunnel slettet",
"c2sPresetLoadError": "Kunne ikke laste inn forhåndsinnstillinger for klienttunnel",
"c2sPresetLoaded": "Forhåndsinnstilling for klienttunnel lastes inn lokalt",
"c2sPresetName": "Forhåndsinnstilt navn",
"c2sPresetNamePlaceholder": "Klientens forhåndsinnstilte navn",
"c2sPresetRenamed": "Forhåndsinnstilling for klienttunnel omdøpt",
"c2sPresetSaved": "Forhåndsinnstilling for klienttunnel lagret",
"c2sPresetSyncNote": "Forhåndsinnstillinger er eksplisitte øyeblikksbilder; lasting av et erstatter denne skrivebordsklientens lokale klienttunnelliste.",
"c2sPresetToLoad": "Forhåndsinnstilt for lasting",
"c2sTunnelPresets": "Forhåndsinnstillinger for klienttunnel",
"c2sTunnelPresetsUnavailable": "Forhåndsinnstillinger for klienttunnel er bare tilgjengelige i skrivebordsklienten."
},
"tunnels": {
"attempt": "Forsøk {{current}} av {{max}}",
"autoNameClientDynamic": "SOKKER {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}",
"autoStart": "Automatisk start",
"cancel": "Kansellere",
"canceling": "Avbryter...",
"clientAutoStartDesc": "Starter når denne skrivebordsklienten åpnes og forblir tilkoblet.",
"clientManualStartDesc": "Bruk Start og Stopp fra denne raden. Termix vil ikke åpne den automatisk.",
"clientRemoteServerNote": "Ekstern videresending kan kreve AllowTcpForwarding og GatewayPorts på endepunktets SSH-server. Den eksterne porten lukkes når dette skrivebordet kobles fra.",
"clientTunnelStarted": "Klienttunnel startet",
"clientTunnelStopped": "Klienttunnelen stoppet",
"clientTunnels": "Klienttunneler",
"connected": "Tilkoblet",
"connecting": "Kobler til...",
"createFirstTunnelMessage": "Du har ikke opprettet noen SSH-tunneler ennå. Konfigurer tunneltilkoblinger i Host Manager for å komme i gang.",
"currentHostPort": "Gjeldende vertsport",
"destination": "Destinasjon",
"destinationAddress": "Destinasjonsadresse",
"destinationPort": "Destinasjonshavn",
"disconnected": "Frakoblet",
"duplicateAutoStartBind": "Bare én automatisk-start-klientunnel kan bruke {{bind}}.",
"endpointPort": "Endepunktport",
"endpointSshHost": "Endepunkt SSH-vert",
"endpointSshHostPlaceholder": "Velg en konfigurert vert",
"endpointSshHostRequired": "Velg en SSH-vert for endepunktet for hver klienttunnel.",
"error": "Feil",
"forwardDescriptionClientDynamic": "SOCKS på lokal port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokal {{sourcePort}} → ekstern {{endpointPort}}.",
"forwardDescriptionClientRemote": "Fjern {{sourcePort}} → lokal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS på gjeldende vert {{sourcePort}}.",
"forwardDescriptionServerLocal": "Nåværende vert {{sourcePort}} → endepunkt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endepunkt {{endpointPort}} → gjeldende vert {{sourcePort}}.",
"host": "Vert",
"intermediateHost": "Mellomliggende vert",
"invalidBindIp": "Lokal IP-adresse må være en gyldig IPv4-adresse.",
"invalidEndpointPort": "Endepunktporten må være mellom 1 og 65535.",
"invalidLocalPort": "Lokal port må være mellom 1 og 65535.",
"invalidLocalTargetIp": "Lokal mål-IP må være en gyldig IPv4-adresse.",
"invalidLocalTargetPort": "Den lokale målporten må være mellom 1 og 65535.",
"invalidRemoteAddress": "Ekstern adresse er påkrevd.",
"invalidRemotePort": "Ekstern port må være mellom 1 og 65535.",
"lastError": "Siste feil",
"lastStarted": "Sist startet",
"lastTested": "Sist testet",
"localAddress": "Lokal adresse",
"localPort": "Lokal havn",
"localSaveError": "Kunne ikke lagre lokale klienttunneler",
"localSaved": "Klienttunneler lagret",
"manualControlError": "Kunne ikke oppdatere tunnelstatus.",
"maxRetries": "Maks antall nye forsøk",
"mode": "Modus",
"nextRetryIn": "Neste forsøk om {{seconds}} sekunder",
"noClientTunnels": "Ingen klienttunneler er konfigurert på dette skrivebordet.",
"noHostSelected": "Ingen vert valgt",
"noSshTunnels": "Ingen SSH-tunneler",
"port": "Havn",
"remoteBindAddress": "Fjernbindingsadresse",
"remoteHost": "Ekstern vert",
"remotePort": "Ekstern port",
"retryInterval": "Intervall for nye forsøk (sekunder)",
"route": "Rute:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOKKER via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} på {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Test",
"tunnelName": "Tunnelnavn",
"tunnelTestFailed": "Tunneltest mislyktes",
"tunnelTestSucceeded": "Tunneltesten var vellykket",
"type": "Tunneltype",
"typeDynamic": "Dynamisk (-D)",
"typeLocal": "Lokal (-L)",
"typeRemote": "Fjernkontroll (-R)",
"working": "Arbeider..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"nav": {
"portForwarding": "Przekierowanie",
"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 (nasłuchiwanie na punkcie końcowym)",
"remoteTargetHost": "127.0.0.1 (cel z punktu końcowego)",
"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."
},
"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": "Adres docelowy",
"destinationPort": "Port docelowy",
"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": "Żywiciel pośredni",
"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": "Wymagany jest adres zdalny.",
"invalidRemotePort": "Port zdalny musi mieścić się w zakresie od 1 do 65535.",
"lastError": "Ostatni błąd",
"lastStarted": "Ostatnio uruchomiony",
"lastTested": "Ostatnio testowane",
"localAddress": "Adres lokalny",
"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": "Zdalny adres powiązania",
"remoteHost": "Zdalny host",
"remotePort": "Zdalny port",
"retryInterval": "Interwał ponawiania (sekundy)",
"route": "Trasa:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS przez {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} przez {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} na {{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..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "Adicionar túnel",
"autoStartDesc": "Conecte este túnel automaticamente quando o host estiver carregado.",
"autoStartLabel": "Início automático",
"connectBtn": "Conectar",
"disconnectBtn": "Desconectar",
"enableTunneling": "Habilitar o tunelamento",
"enableTunnelingDesc": "Ative a funcionalidade de túnel SSH para este host.",
"endpointHost": "Host do ponto final",
"endpointHostPlaceholder": "Mesmo host, host SSH ou host/IP acessível",
"endpointPort": "Porta de ponto final",
"failedToConnectTunnel": "Falha ao conectar",
"failedToDisconnectTunnel": "Falha ao desconectar",
"maxRetries": "Número máximo de tentativas",
"noTunnelsConfigured": "Nenhum túnel configurado.",
"retryIntervalS": "Intervalo de tentativas (s)",
"serverTunnelsSection": "Túneis de servidor",
"sourcePort": "Porta de origem",
"tabTunnels": "Túneis",
"tunnelConnecting": "Túnel de ligação...",
"tunnelDisconnected": "Túnel desconectado",
"tunnelLabel": "Túnel {{number}}",
"tunnelModeDynamicDesc": "Crie um proxy SOCKS5 em uma porta local para encaminhamento dinâmico de portas.",
"tunnelModeLocalDesc": "Encaminhar uma porta local para uma porta no servidor remoto (ou em um host acessível a partir dele).",
"tunnelModeRemoteDesc": "Encaminhe uma porta no servidor remoto de volta para uma porta local em sua máquina.",
"tunnelRequirementsText": "O servidor SSH deve ter as seguintes configurações definidas em /etc/ssh/sshd_config: GatewayPorts yes, AllowTcpForwarding yes e PermitRootLogin yes.",
"tunnelSettings": "Configurações do túnel",
"tunnelType": "Tipo de túnel"
},
"nav": {
"portForwarding": "Encaminhamento",
"tunnels": "Túneis"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (ouvir localmente)",
"localTargetHost": "127.0.0.1 (alvo neste computador)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (escutar no ponto de extremidade)",
"remoteTargetHost": "127.0.0.1 (destino do ponto de extremidade)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (ouvinte SOCKS)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} Túneis de cliente local configurados nesta área de trabalho.",
"c2sLoadPreset": "Carregar",
"c2sNoPresetSelected": "Nenhuma predefinição selecionada",
"c2sNoPresets": "Nenhuma predefinição salva",
"c2sPresetDeleted": "Configuração de túnel do cliente excluída",
"c2sPresetLoadError": "Falha ao carregar as predefinições do túnel do cliente.",
"c2sPresetLoaded": "Configuração predefinida do túnel do cliente carregada localmente",
"c2sPresetName": "Nome predefinido",
"c2sPresetNamePlaceholder": "Nome predefinido do cliente",
"c2sPresetRenamed": "A predefinição do túnel do cliente foi renomeada.",
"c2sPresetSaved": "Configuração predefinida do túnel do cliente salva",
"c2sPresetSyncNote": "As predefinições são instantâneos explícitos; carregar uma substitui a lista de túneis de clientes locais deste cliente de desktop.",
"c2sPresetToLoad": "Predefinido para carregar",
"c2sTunnelPresets": "Configurações predefinidas do túnel do cliente",
"c2sTunnelPresetsUnavailable": "As configurações predefinidas de túnel do cliente estão disponíveis apenas no cliente para desktop."
},
"tunnels": {
"attempt": "Tentativa {{current}} de {{max}}",
"autoNameClientDynamic": "MEIAS {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoStart": "Início automático",
"cancel": "Cancelar",
"canceling": "Cancelando...",
"clientAutoStartDesc": "Inicia quando este cliente de desktop é aberto e permanece conectado.",
"clientManualStartDesc": "Use os botões Iniciar e Parar a partir desta linha. O Termix não os abrirá automaticamente.",
"clientRemoteServerNote": "O encaminhamento remoto pode exigir as permissões AllowTcpForwarding e GatewayPorts no servidor SSH do endpoint. A porta remota é fechada quando esta área de trabalho se desconecta.",
"clientTunnelStarted": "O túnel do cliente foi iniciado.",
"clientTunnelStopped": "O túnel do cliente foi interrompido.",
"clientTunnels": "Túneis para clientes",
"connected": "Conectado",
"connecting": "Conectando...",
"createFirstTunnelMessage": "Você ainda não criou nenhum túnel SSH. Configure as conexões de túnel no Gerenciador de Hosts para começar.",
"currentHostPort": "Porta do host atual",
"destination": "Destino",
"destinationAddress": "Endereço de destino",
"destinationPort": "Porto de destino",
"disconnected": "Desconectado",
"duplicateAutoStartBind": "Apenas um túnel de cliente de inicialização automática pode usar {{bind}}.",
"endpointPort": "Porta de ponto final",
"endpointSshHost": "Host SSH de ponto final",
"endpointSshHostPlaceholder": "Selecione um host configurado",
"endpointSshHostRequired": "Selecione um host SSH de ponto final 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": "Ponto final {{endpointPort}} → host atual {{sourcePort}}.",
"host": "Hospedar",
"intermediateHost": "Anfitrião intermediário",
"invalidBindIp": "O endereço IP local deve ser um endereço IPv4 válido.",
"invalidEndpointPort": "A porta do ponto final deve estar entre 1 e 65535.",
"invalidLocalPort": "A porta local deve estar entre 1 e 65535.",
"invalidLocalTargetIp": "O endereç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": "É necessário um endereço remoto.",
"invalidRemotePort": "A porta remota deve estar entre 1 e 65535.",
"lastError": "Último erro",
"lastStarted": "Última vez iniciado",
"lastTested": "Último teste",
"localAddress": "Endereço local",
"localPort": "Porto local",
"localSaveError": "Falha ao salvar os túneis do cliente local.",
"localSaved": "Túneis de clientes salvos",
"manualControlError": "Falha ao atualizar o estado do túnel.",
"maxRetries": "Número máximo de tentativas",
"mode": "Modo",
"nextRetryIn": "Próxima tentativa em {{seconds}} segundos",
"noClientTunnels": "Nenhum túnel de cliente configurado nesta área de trabalho.",
"noHostSelected": "Nenhum host selecionado",
"noSshTunnels": "Sem túneis SSH",
"port": "Porta",
"remoteBindAddress": "Endereço de vinculação remota",
"remoteHost": "Host remoto",
"remotePort": "Porta remota",
"retryInterval": "Intervalo de tentativas (segundos)",
"route": "Rota:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → MEIAS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} em {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Teste",
"tunnelName": "Nome do túnel",
"tunnelTestFailed": "O teste do túnel falhou",
"tunnelTestSucceeded": "O teste no túnel foi bem-sucedido.",
"type": "Tipo de túnel",
"typeDynamic": "Dinâmico (-D)",
"typeLocal": "Local (-L)",
"typeRemote": "Remoto (-R)",
"working": "Trabalhando..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"nav": {
"portForwarding": "Encaminhamento",
"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 (escutar no ponto final)",
"remoteTargetHost": "127.0.0.1 (destino do ponto final)",
"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."
},
"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": "Endereço de destino",
"destinationPort": "Porto de destino",
"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": "Anfitrião intermediário",
"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": "É necessário um endereço remoto.",
"invalidRemotePort": "A porta remota deve estar entre 1 e 65535.",
"lastError": "Último erro",
"lastStarted": "Último início",
"lastTested": "Último teste",
"localAddress": "Endereço local",
"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": "Endereço de ligação remota",
"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}} em {{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..."
}
}
@@ -0,0 +1,139 @@
{
"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ă punct final",
"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"
},
"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."
},
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "Тип туннеля"
},
"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": "Предустановки клиентских туннелей доступны только в десктопном клиенте."
},
"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}} на {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Проверить",
"tunnelName": "Имя туннеля",
"tunnelTestFailed": "Проверка туннеля не удалась",
"tunnelTestSucceeded": "Проверка туннеля успешна",
"type": "Тип туннеля",
"typeDynamic": "Динамический (-D)",
"typeLocal": "Локальный (-L)",
"typeRemote": "Удалённый (-R)",
"working": "Выполняется..."
}
}
@@ -0,0 +1,139 @@
{
"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, AllowTcpForwarding и PermitRootLogin у /etc/ssh/sshd_config.",
"tunnelSettings": "Подешавања тунела",
"tunnelType": "Тип тунела"
},
"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": "Унапред подешена подешавања тунела клијента доступна су само у десктоп клијенту."
},
"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": "Динамички (-Д)",
"typeLocal": "Локално (-Л)",
"typeRemote": "Даљински (-R)",
"working": "Ради се..."
}
}
@@ -0,0 +1,139 @@
{
"hosts": {
"addTunnelBtn": "Lägg till tunnel",
"autoStartDesc": "Anslut automatiskt den här tunneln när värden laddas",
"autoStartLabel": "Autostart",
"connectBtn": "Ansluta",
"disconnectBtn": "Koppla från",
"enableTunneling": "Aktivera tunnelering",
"enableTunnelingDesc": "Aktivera SSH-tunnelfunktionalitet för den här värden",
"endpointHost": "Slutpunktsvärd",
"endpointHostPlaceholder": "Samma värd, SSH-värd eller nåbar värd/IP",
"endpointPort": "Slutpunktsport",
"failedToConnectTunnel": "Misslyckades med att ansluta",
"failedToDisconnectTunnel": "Misslyckades med att koppla från",
"maxRetries": "Max antal återförsök",
"noTunnelsConfigured": "Inga tunnlar konfigurerade.",
"retryIntervalS": "Intervall för återförsök (s)",
"serverTunnelsSection": "Servertunnlar",
"sourcePort": "Källport",
"tabTunnels": "Tunnlar",
"tunnelConnecting": "Tunnelförbindelse...",
"tunnelDisconnected": "Tunneln frånkopplad",
"tunnelLabel": "Tunnel {{number}}",
"tunnelModeDynamicDesc": "Skapa en SOCKS5-proxy på en lokal port för dynamisk portvidarebefordran.",
"tunnelModeLocalDesc": "Vidarebefordra en lokal port till en port på fjärrservern (eller en värd som är nåbar från den).",
"tunnelModeRemoteDesc": "Vidarebefordra en port på fjärrservern tillbaka till en lokal port på din maskin.",
"tunnelRequirementsText": "SSH-servern måste ha GatewayPorts ja, AllowTcpForwarding ja och PermitRootLogin ja angivna i /etc/ssh/sshd_config.",
"tunnelSettings": "Tunnelinställningar",
"tunnelType": "Tunneltyp"
},
"nav": {
"portForwarding": "Spedition",
"tunnels": "Tunnlar"
},
"placeholders": {
"defaultEndpointPort": "224",
"defaultPort": "22",
"localListenerHost": "127.0.0.1 (lyssna lokalt)",
"localTargetHost": "127.0.0.1 (mål på den här datorn)",
"maxRetries": "3",
"remoteBindHost": "127.0.0.1 (lyssna på slutpunkten)",
"remoteTargetHost": "127.0.0.1 (mål från slutpunkt)",
"retryInterval": "10",
"socksListenerHost": "127.0.0.1 (SOCKS-lyssnare)"
},
"profile": {
"c2sCurrentLocalConfig": "{{count}} lokala klienttunnlar konfigurerade på den här skrivbordet.",
"c2sLoadPreset": "Ladda",
"c2sNoPresetSelected": "Ingen förinställning vald",
"c2sNoPresets": "Inga förinställningar sparade",
"c2sPresetDeleted": "Förinställningen för klienttunneln har raderats",
"c2sPresetLoadError": "Misslyckades med att läsa in förinställningar för klienttunneln",
"c2sPresetLoaded": "Förinställningen för klienttunnel laddas lokalt",
"c2sPresetName": "Förinställt namn",
"c2sPresetNamePlaceholder": "Klientförinställningsnamn",
"c2sPresetRenamed": "Förinställningen för klienttunneln har bytt namn",
"c2sPresetSaved": "Förinställningen för klienttunneln sparad",
"c2sPresetSyncNote": "Förinställningar är explicita ögonblicksbilder; om en sådan läses in ersätts den här skrivbordsklientens lokala klienttunnellista.",
"c2sPresetToLoad": "Förinställning att ladda",
"c2sTunnelPresets": "Förinställningar för klienttunnel",
"c2sTunnelPresetsUnavailable": "Förinställningar för klienttunneln är endast tillgängliga i skrivbordsklienten."
},
"tunnels": {
"attempt": "Försök {{current}} av {{max}}",
"autoNameClientDynamic": "STRUMPOCKOR {{localPort}} via {{endpoint}}",
"autoNameClientLocal": "Lokal {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → lokal {{localPort}}",
"autoStart": "Automatisk start",
"cancel": "Avboka",
"canceling": "Inställande...",
"clientAutoStartDesc": "Startar när den här skrivbordsklienten öppnas och förblir ansluten.",
"clientManualStartDesc": "Använd Start och Stopp från den här raden. Termix öppnar den inte automatiskt.",
"clientRemoteServerNote": "Fjärrvidarebefordran kan kräva AllowTcpForwarding och GatewayPorts på slutpunktens SSH-server. Fjärrporten stängs när den här skrivbordsmiljön kopplas bort.",
"clientTunnelStarted": "Klienttunneln har startats",
"clientTunnelStopped": "Klienttunneln stoppad",
"clientTunnels": "Klienttunnlar",
"connected": "Ansluten",
"connecting": "Ansluter...",
"createFirstTunnelMessage": "Du har inte skapat några SSH-tunnlar än. Konfigurera tunnelanslutningar i Host Manager för att komma igång.",
"currentHostPort": "Nuvarande värdport",
"destination": "Destination",
"destinationAddress": "Destinationsadress",
"destinationPort": "Destinationshamn",
"disconnected": "Osammanhängande",
"duplicateAutoStartBind": "Endast en automatiskt startad klienttunnel kan använda {{bind}}.",
"endpointPort": "Slutpunktsport",
"endpointSshHost": "Slutpunkts-SSH-värd",
"endpointSshHostPlaceholder": "Välj en konfigurerad värd",
"endpointSshHostRequired": "Välj en SSH-värd för slutpunkten för varje klienttunnel.",
"error": "Fel",
"forwardDescriptionClientDynamic": "SOCKS på lokal port {{sourcePort}}.",
"forwardDescriptionClientLocal": "Lokal {{sourcePort}} → fjärrkontroll {{endpointPort}}.",
"forwardDescriptionClientRemote": "Fjärrstyrd {{sourcePort}} → lokal {{endpointPort}}.",
"forwardDescriptionServerDynamic": "SOCKS på nuvarande värd {{sourcePort}}.",
"forwardDescriptionServerLocal": "Nuvarande värd {{sourcePort}} → slutpunkt {{endpointPort}}.",
"forwardDescriptionServerRemote": "Slutpunkt {{endpointPort}} → aktuell värd {{sourcePort}}.",
"host": "Värd",
"intermediateHost": "Mellanvärd",
"invalidBindIp": "Lokal IP-adress måste vara en giltig IPv4-adress.",
"invalidEndpointPort": "Slutpunktsporten måste vara mellan 1 och 65535.",
"invalidLocalPort": "Den lokala porten måste vara mellan 1 och 65535.",
"invalidLocalTargetIp": "Den lokala mål-IP-adressen måste vara en giltig IPv4-adress.",
"invalidLocalTargetPort": "Den lokala målporten måste vara mellan 1 och 65535.",
"invalidRemoteAddress": "Fjärradress krävs.",
"invalidRemotePort": "Fjärrporten måste vara mellan 1 och 65535.",
"lastError": "Senaste felet",
"lastStarted": "Senast startade",
"lastTested": "Senast testad",
"localAddress": "Lokal adress",
"localPort": "Lokal hamn",
"localSaveError": "Misslyckades med att spara lokala klienttunnlar",
"localSaved": "Klienttunnlar sparade",
"manualControlError": "Misslyckades med att uppdatera tunnelns tillstånd.",
"maxRetries": "Max antal återförsök",
"mode": "Läge",
"nextRetryIn": "Nästa försök om {{seconds}} sekunder",
"noClientTunnels": "Inga klienttunnlar konfigurerade på den här skrivbordet.",
"noHostSelected": "Ingen värd vald",
"noSshTunnels": "Inga SSH-tunnlar",
"port": "Hamn",
"remoteBindAddress": "Fjärrbindningsadress",
"remoteHost": "Fjärrvärd",
"remotePort": "Fjärrport",
"retryInterval": "Intervall för återförsök (sekunder)",
"route": "Rutt:",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{remoteHost}}:{{remotePort}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} på {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "Testa",
"tunnelName": "Tunnelnamn",
"tunnelTestFailed": "Tunneltestet misslyckades",
"tunnelTestSucceeded": "Tunneltestet lyckades",
"type": "Tunneltyp",
"typeDynamic": "Dynamisk (-D)",
"typeLocal": "Lokal (-L)",
"typeRemote": "Fjärrkontroll (-R)",
"working": "Arbetssätt..."
}
}
@@ -0,0 +1,139 @@
{
"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": "ประเภทอุโมงค์"
},
"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": "การตั้งค่าล่วงหน้าสำหรับอุโมงค์ไคลเอ็นต์มีให้ใช้งานเฉพาะในไคลเอ็นต์เดสก์ท็อปเท่านั้น"
},
"tunnels": {
"attempt": "ความพยายาม {{current}} ของ {{max}}",
"autoNameClientDynamic": "ถุงเท้า {{localPort}} ผ่าน {{endpoint}}",
"autoNameClientLocal": "โลคอล {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → โลคอล {{localPort}}",
"autoStart": "เริ่มอัตโนมัติ",
"cancel": "ยกเลิก",
"canceling": "ยกเลิก...",
"clientAutoStartDesc": "เริ่มทำงานเมื่อโปรแกรมไคลเอ็นต์บนเดสก์ท็อปนี้เปิดขึ้นและเชื่อมต่ออยู่ตลอดเวลา",
"clientManualStartDesc": "ใช้ปุ่ม Start และ Stop จากแถวนี้ 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": "การทำงาน..."
}
}
@@ -0,0 +1,139 @@
{
"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ü"
},
"nav": {
"portForwarding": "İletme",
"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 (uç noktada dinleme)",
"remoteTargetHost": "127.0.0.1 (uç noktadan hedef)",
"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."
},
"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": "Hedef Adresi",
"destinationPort": "Varış Limanı",
"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": "Ara Ev Sahibi",
"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": "Uzak adres gereklidir.",
"invalidRemotePort": "Uzak port 1 ile 65535 arasında olmalıdır.",
"lastError": "Son hata",
"lastStarted": "Son başlatma",
"lastTested": "Son test",
"localAddress": "Yerel Adres",
"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": "Uzak Bağlantı Adresi",
"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..."
}
}
@@ -0,0 +1,139 @@
{
"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: «так», AllowTcpForwarding: «так» та PermitRootLogin: «так».",
"tunnelSettings": "Налаштування тунелю",
"tunnelType": "Тип тунелю"
},
"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": "Попередні налаштування тунелю клієнта доступні лише у клієнтській версії для настільних комп'ютерів."
},
"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": "Працюємо..."
}
}
@@ -0,0 +1,139 @@
{
"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"
},
"nav": {
"portForwarding": "Chuyển tiếp",
"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 (lắng nghe trên điểm cuối)",
"remoteTargetHost": "127.0.0.1 (mục tiêu từ điểm cuối)",
"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."
},
"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": "Địa chỉ đích",
"destinationPort": "Cảng đích",
"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": "Máy chủ trung gian",
"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": "Cần có địa chỉ từ xa.",
"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": "Địa chỉ địa phương",
"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": "Địa chỉ liên kết từ xa",
"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}} qua {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} trên {{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..."
}
}
@@ -0,0 +1,139 @@
{
"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": "隧道类型"
},
"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": "客户端隧道预设仅在桌面客户端中可用。"
},
"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": "当前主机上的 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}} 在 {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "测试",
"tunnelName": "隧道名称",
"tunnelTestFailed": "隧道测试失败",
"tunnelTestSucceeded": "隧道测试成功",
"type": "隧道类型",
"typeDynamic": "动态 (-D)",
"typeLocal": "本地 (-L)",
"typeRemote": "远程 (-R)",
"working": "处理中..."
}
}
@@ -0,0 +1,139 @@
{
"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": "通道類型"
},
"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": "用戶端通道預設組僅在桌面用戶端中可用。"
},
"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": "目前主機上的 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}} via {{endpoint}}",
"summaryClientRemote": "{{remoteHost}}:{{remotePort}} on {{endpoint}} → {{localHost}}:{{localPort}}",
"test": "測試",
"tunnelName": "通道名稱",
"tunnelTestFailed": "通道測試失敗",
"tunnelTestSucceeded": "通道測試成功",
"type": "通道類型",
"typeDynamic": "動態 (-D)",
"typeLocal": "本機 (-L)",
"typeRemote": "遠端 (-R)",
"working": "處理中..."
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"id": "tunnels",
"name": "Tunnels",
"version": "1.0.0",
"description": "SSH port forwarding between servers, and client 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"
],
"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",
"fields": [
{
"key": "tunnelConnections",
"type": "custom",
"component": "tunnelConnections",
"requires": "enableTunnel"
}
]
}
},
"hostCapability": {
"key": "enableTunnel",
"labelKey": "hosts.tabTunnels",
"editorTab": "general"
}
},
"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";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/tunnels",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+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;
}
+450
View File
@@ -0,0 +1,450 @@
import type { Duplex } from "node:stream";
import type { Client, ClientChannel } from "ssh2";
import type { WebSocket } from "ws";
import type {
PluginContext,
PluginSshConnection,
} from "@termix/plugin-sdk/backend";
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;
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";
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;
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;
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,
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,
};
}
function connectSource(hostId: number): Promise<PluginSshConnection<Client>> {
return ctx.ssh.connect<Client>(hostId, {
purpose: "tunnel",
timeoutMs: 60_000,
});
}
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(relay.hostId);
const sourceClient = source.client;
let actualPort: number;
try {
actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
} catch (error) {
source.dispose();
throw error;
}
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(relay.hostId);
let outbound: ClientChannel;
try {
outbound = await forwardOut(source.client, targetHost, targetPort);
} catch (error) {
source.dispose();
throw error;
}
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(relay.hostId);
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 {
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.
}
+838
View File
@@ -0,0 +1,838 @@
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",
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;
}
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>
</>
);
}
+364
View File
@@ -0,0 +1,364 @@
import { useEffect, useState } from "react";
import {
AlertCircle,
Clock,
ExternalLink,
Network,
Play,
RefreshCw,
Settings,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import {
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), []);
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>
);
}
+248
View File
@@ -0,0 +1,248 @@
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;
/** Set from activate with app.api, cleared on deactivate. */
export function setTunnelsApi(api: PluginApiClient | null): void {
client = api;
}
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);
});
const url = streamUrl();
void (async () => {
while (!controller.signal.aborted) {
if (!url) {
try {
latestLocal = await getTunnelStatuses();
emit();
} catch {
onError?.();
}
await wait(pollIntervalMs);
continue;
}
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
if (typeof window !== "undefined" && window.electronAPI?.isElectron) {
headers["X-Electron-App"] = "true";
const jwt = localStorage.getItem("jwt");
if (jwt) headers.Authorization = `Bearer ${jwt}`;
}
try {
const response = await fetchImpl(url, {
credentials: "include",
headers,
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 (options.fetchRemote) {
const fetchRemote = options.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,
};
}
+104
View File
@@ -0,0 +1,104 @@
import type { ComponentType } from "react";
import { Network, Plug } from "lucide-react";
import type {
HomepageWidgetContribution,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { TunnelTab } from "./TunnelTab";
import { HostTunnelsSection } from "./HostTunnelsSection";
import {
PortForwardingPanel,
TunnelStandalone,
TunnelWidget,
TunnelWidgetEditForm,
} from "./views";
import { getTunnelStatuses, setTunnelsApi } from "./api";
import { hostTunnelSettings } from "./host-tunnels";
// Matches the homepage canvas grid.
const GRID_SIZE = 30;
function TunnelTabView({ host }: TabProps) {
return <TunnelTab host={host} />;
}
export function activate(app: TermixApp): void {
setTunnelsApi(app.api);
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.registerHomepageWidget({
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 },
component: TunnelWidget,
editFormComponent: TunnelWidgetEditForm,
} as HomepageWidgetContribution);
// The dashboard's active tunnel counter reads and opens these, and gets
// undefined back while the plugin is off.
app.registerAction("tunnels.statuses", (() => getTunnelStatuses()) as never, {
permission: "use",
});
app.registerAction(
"tunnels.open",
(() => app.tabs.openSingletonTab("tunnel")) as never,
{ permission: "use" },
);
}
@@ -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("homepage.widgetNoHostSelected"));
if (!loaded) return placeholder(t("homepage.loading"));
const host = hosts.find(
(candidate) =>
String(candidate.id) === String(config.hostId) &&
hostTunnelSettings(candidate).enabled,
);
if (!host) return placeholder(t("homepage.widgetNoHostSelected"));
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("homepage.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("homepage.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,83 @@
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).toEqual([{ 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,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",
"tunnels.statuses",
]);
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,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);
+9 -15
View File
@@ -22,12 +22,9 @@ Each host can define up to 16 endpoints (`MAX_WEB_ENDPOINTS`). An endpoint is ei
## What stays in core
This plugin owns its lifecycle, not all of its code. Four pieces deliberately stay in
This plugin owns its lifecycle, not all of its code. Three pieces deliberately stay in
Termix proper:
- **The tunnel manager** (`src/backend/hosts/tunnel/manager.ts`). It serves the whole
server-tunnels feature. The reserved `web:` tunnel-name prefix that exempts these
tunnels from the retry machinery is part of its own disconnect path.
- **`host-web-endpoints.ts`** (`src/backend/database/routes/`). Despite sitting in
`routes/` it has no router — it is the `webUiConfig` normalizer, imported by `host.ts`,
`host-bulk-routes.ts` and `host-normalizers.ts` to sanitize the column on every host
@@ -44,21 +41,18 @@ Termix proper:
The `enableWebUi` and `webUiConfig` columns stay on core's `ssh_data` table, declared
here through `contributes.hostCapability`.
## Why it runs in-process
## Tunnels
The open route polls the tunnel manager's live maps, builds a `TunnelConfig` from
plaintext host credentials that `ctx.hosts` deliberately withholds, and hands the
resulting live `ssh2.Client` to `forwardOut`. A structured-clone postMessage boundary
can carry none of those, so this is a genuine transport dependency — see
`src/backend/plugins/first-party.ts`.
A tunnel endpoint opens through the tunnels plugin, a hard dependency. `POST /open`
calls `forward()` on its `tunnels.access` service, which connects through core's SSH
pipeline, binds the local listener, probes the target once and only then returns the
bound port. The forward runs under the reserved `web:<hostId>:<endpointId>` name, so
the tunnels plugin never retries it and closes it after ten idle minutes.
## Routing
The route is served by the tunnel service on port 30003, so the dispatcher lives at
`src/backend/hosts/tunnel/web-endpoint-dispatch.ts` rather than in `database.ts` with
the others. The public path stays `/ssh/tunnel/web-endpoint/open`, which the generic
`location /ssh/tunnel/` block in both nginx configs already proxies — so no nginx change
was needed.
Core mounts the route at `/plugin-api/web-endpoint/open` through `ctx.http.router()`,
which the generic `/plugin-api/` block in both nginx configs already proxies.
## Tests
+9 -6
View File
@@ -14,12 +14,15 @@
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"credentials:use",
"ssh:connect",
"network:serve",
"ui:surface"
"capabilities": ["hosts:read", "network:serve", "ui:surface"],
"dependencies": {
"tunnels": "^1.0.0"
},
"requires": [
{
"service": "tunnels.access",
"versionRange": "^1.0.0"
}
],
"contributes": {
"tabs": [
+2 -4
View File
@@ -1,11 +1,9 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startWebEndpointService(ctx.http.router());
startWebEndpointService(ctx.http.router(), ctx);
ctx.disposables.add(() => stopWebEndpointService());
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
}
+68 -240
View File
@@ -1,133 +1,45 @@
import express, { type Router } from "express";
import { Client } from "ssh2";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { tunnelLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseWebUiConfig } from "../../../../src/backend/database/routes/host-web-endpoints.js";
import {
activeTunnelRuntimes,
cleanupTunnelResources,
connectSSHTunnel,
connectionStatus,
tunnelConnecting,
} from "../../../../src/backend/hosts/tunnel/manager.js";
import { forwardOut } from "../../../../src/backend/hosts/tunnel/ssh-primitives.js";
import { buildWebEndpointTunnelName } from "../../../../src/backend/hosts/tunnel/utils.js";
import type { TunnelConfig, WebEndpoint } from "../../../../src/types/index.js";
/** Matches the spec's ten minutes. */
const WEB_ENDPOINT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Long enough for a slow SSH handshake, short enough that a wedged connection
* does not hold the HTTP request open indefinitely. One second past the
* manager's own connection timeout so the manager reports first.
*/
const TUNNEL_READY_TIMEOUT_MS = 61_000;
const TUNNEL_READY_POLL_MS = 100;
const TARGET_PROBE_TIMEOUT_MS = 10_000;
/** The tunnels plugin, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: {
targetHost: string;
targetPort: number;
bindHost?: string;
bindPort?: number;
},
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{ bindHost: string; bindPort: number }>;
}
/**
* What the live forward was built for. Compared on reuse so an endpoint edited
* under a live tunnel is reopened rather than silently forwarding to the old
* target.
*
* JSON rather than a joined string: no separator can be smuggled in by a field
* value, and the result stays readable in a log. The SSH identity is included
* because a host repointed at a different server must also invalidate the
* forward, not just a changed endpoint port.
* Reserved by the tunnels plugin for on-demand forwards: never retried, and
* the host id in it is what the tunnels plugin checks before letting anyone
* stop one by name.
*/
function targetFingerprint(
endpoint: Pick<
WebEndpoint,
"scheme" | "port" | "access" | "bindHost" | "localPort"
>,
host: { ip?: string; port?: number; username?: string },
export function webEndpointTunnelName(
hostId: number,
endpointId: string,
): string {
return JSON.stringify({
scheme: endpoint.scheme,
port: endpoint.port,
access: endpoint.access,
bindHost: endpoint.bindHost ?? null,
localPort: endpoint.localPort ?? null,
ip: host.ip ?? null,
sshPort: host.port ?? null,
username: host.username ?? null,
});
return `web:${hostId}:${endpointId}`;
}
const openedTargets = new Map<string, string>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* connectSSHTunnel(config) returns Promise<void> and NEVER REJECTS: every
* failure path inside it reports over broadcastTunnelStatus and returns
* normally, and its own promise resolves right after conn.connect(), long
* before the SSH "ready" event that would populate activeTunnelRuntimes.
*
* So "awaited" is not "connected". This is what actually waits.
*
* Polling the maps the manager already exports, rather than adding a
* promise-returning variant to manager.ts: every existing caller of
* connectSSHTunnel is fire-and-forget by design (the connect route responds
* "Connection request received" immediately and reports asynchronously over
* the status broadcast), so changing that contract to serve one new caller is
* a far larger blast radius than polling here.
*/
async function waitForTunnelSettled(tunnelName: string): Promise<void> {
const deadline = Date.now() + TUNNEL_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (activeTunnelRuntimes.has(tunnelName)) return;
const status = connectionStatus.get(tunnelName);
if (!tunnelConnecting.has(tunnelName) && status && !status.connected) {
throw new Error(status.reason || "The endpoint tunnel failed to connect");
}
await sleep(TUNNEL_READY_POLL_MS);
}
throw new Error("Timed out establishing the endpoint tunnel");
}
/**
* Opens one channel to the target and closes it immediately.
*
* Without this the route returns 200 the moment tcpServer.listen succeeds, and
* forwardOut is only attempted per inbound socket -- where a failure is
* swallowed by the socket's own error handling. The user then gets a blank
* frame and no message for the single likeliest error: nothing is actually
* listening on that port. Costs one round trip per open.
*/
async function probeEndpointTarget(
sourceClient: Client,
targetPort: number,
tunnelName: string,
): Promise<void> {
const channel = await Promise.race([
forwardOut(sourceClient, "127.0.0.1", targetPort, tunnelName),
new Promise<never>((_resolve, reject) =>
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.
}
}
let current: PluginContext | null = null;
export async function handleWebEndpointOpen(
req: express.Request,
res: express.Response,
): Promise<express.Response | void> {
const userId = (req as { userId?: string }).userId;
if (!userId) {
const ctx = current;
const userId = ctx?.currentActor();
if (!ctx || !userId) {
return res.status(401).json({ error: "Authentication required" });
}
@@ -141,10 +53,8 @@ export async function handleWebEndpointOpen(
}
// Deliberately NOT gated to the desktop. The forward binds wherever this
// backend runs, exactly as the server tunnels feature does, and the
// endpoint's own bindHost decides whether that is reachable from the
// browser -- loopback for the desktop, an address the server answers on for
// a web deployment.
// backend runs, and the endpoint's own bindHost decides whether that is
// reachable from the browser.
const { resolveHostById } =
await import("../../../../src/backend/hosts/host-resolver.js");
@@ -153,11 +63,9 @@ export async function handleWebEndpointOpen(
return res.status(403).json({ error: "Host not found or access denied" });
}
// All three write paths null the config on disable, so a configured-but-
// disabled host is not normally reachable -- except a bulk update that sends
// only { webUiConfig } while the stored enable_web_ui is still 0. The UI
// reads as off everywhere in that state, so the endpoint merely being
// present in the config is not on its own a licence to open a tunnel.
// A bulk update that sends only { webUiConfig } can leave the endpoint in
// the config while the feature is off. The UI reads as off in that state,
// so the endpoint being listed is not on its own a licence to open a tunnel.
if (!host.enableWebUi) {
return res
.status(400)
@@ -178,118 +86,42 @@ export async function handleWebEndpointOpen(
.json({ error: "This endpoint does not use a tunnel" });
}
const tunnelName = buildWebEndpointTunnelName(hostId, endpointId);
const fingerprint = targetFingerprint(endpoint, host);
const existing = activeTunnelRuntimes.get(tunnelName);
if (existing) {
const recorded = openedTargets.get(tunnelName);
// The runtime is the source of truth and openedTargets is derived from it,
// so an ABSENT fingerprint means reuse, not staleness. A reserved-prefixed
// runtime can only have been created by this route.
if (recorded === undefined || recorded === fingerprint) {
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: existing.bindPort });
}
tunnelLogger.info("Reopening web endpoint tunnel after config change", {
operation: "web_endpoint_reopen",
tunnelName,
});
// Forced, for the same reason handleDisconnect's reserved-name branch
// forces: cleanupTunnelResources no-ops while tunnelConnecting holds the
// name, and a web tunnel has no retry pass to self-heal a runtime left
// behind. An unforced call could leave a dead listener registered.
await cleanupTunnelResources(tunnelName, true);
openedTargets.delete(tunnelName);
let tunnels: TunnelsAccess;
try {
tunnels = ctx.services.get<TunnelsAccess>("tunnels.access", { userId });
} catch {
return res
.status(503)
.json({ error: "The tunnels plugin is not available" });
}
const tunnelConfig: TunnelConfig = {
name: tunnelName,
scope: "s2s",
mode: "local",
tunnelType: "local",
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with no
// authentication in front of it -- the same trade the server tunnels
// feature exposes, and only ever an explicit per-endpoint choice. The
// value is validated to a bare host literal by normalizeBindHost.
bindHost: endpoint.bindHost || "127.0.0.1",
sourceHostId: host.id ?? hostId,
tunnelIndex: 0,
requestingUserId: userId,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword: host.password,
sourceAuthMethod: host.authType,
sourceSSHKey: host.key,
sourceKeyPassword: host.keyPassword,
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
// connectSSHTunnel reads sourceUserId when building its client.
sourceUserId: host.userId,
endpointIP: host.ip,
endpointSSHPort: host.port,
endpointUsername: host.username,
// Load-bearing: connectSSHTunnel picks its strategy via
// shouldEstablishDirectTunnel -> isSingleHostTunnel, which returns true
// when endpointHost is "127.0.0.1". That is what selects
// establishDirectTunnel's local branch -- the bind-locally-and-forwardOut
// path this feature needs. Setting only targetHost would NOT select it.
endpointHost: "127.0.0.1",
endpointAuthMethod: host.authType,
endpointSSHKey: host.key,
endpointKeyPassword: host.keyPassword,
endpointKeyType: host.keyType,
endpointCredentialId: host.credentialId,
endpointUserId: host.userId,
// A fixed port when the endpoint names one, as the server tunnels
// feature's Source Port does -- a container can only publish ports it
// knows in advance. Otherwise 0 and the kernel picks; the manager reports
// back what it actually bound either way.
sourcePort: endpoint.localPort ?? 0,
endpointPort: endpoint.port,
// Documentation only: maxRetries 0 does NOT disable retries, because the
// manager reads `maxRetries || 3`. Retry is actually suppressed by the
// reserved-name early return in handleDisconnect.
maxRetries: 0,
retryInterval: 0,
autoStart: false,
isPinned: Boolean(host.pin),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
useSocks5: Boolean(host.useSocks5),
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
} as TunnelConfig;
try {
await connectSSHTunnel(tunnelConfig);
await waitForTunnelSettled(tunnelName);
const runtime = activeTunnelRuntimes.get(tunnelName);
if (!runtime) {
throw new Error("The endpoint tunnel closed before it could be used");
}
await probeEndpointTarget(runtime.sourceClient, endpoint.port, tunnelName);
openedTargets.set(tunnelName, fingerprint);
return res.status(200).json({ port: runtime.bindPort });
const handle = await tunnels.forward(
hostId,
{
targetHost: "127.0.0.1",
targetPort: endpoint.port,
// Loopback unless the endpoint asks otherwise. A non-loopback bind
// publishes the target's web UI to anyone who can reach the port, with
// no authentication in front of it, and is only ever an explicit
// per-endpoint choice.
bindHost: endpoint.bindHost || "127.0.0.1",
// A fixed port when the endpoint names one, since a container can only
// publish ports it knows in advance. Otherwise the kernel picks.
bindPort: endpoint.localPort ?? undefined,
},
{
name: webEndpointTunnelName(hostId, endpointId),
idleTimeoutMs: WEB_ENDPOINT_IDLE_TIMEOUT_MS,
},
);
return res.status(200).json({ port: handle.bindPort });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
tunnelLogger.error("Failed to open web endpoint tunnel", error, {
operation: "web_endpoint_open_failed",
tunnelName,
});
// Leave nothing half-built: a listener with no working forward behind it
// would be handed out on the next open as a live tunnel.
await cleanupTunnelResources(tunnelName, true).catch(() => undefined);
openedTargets.delete(tunnelName);
ctx.log.error(
`Failed to open web endpoint tunnel for host ${hostId}: ${reason}`,
error instanceof Error ? error : undefined,
);
return res.status(502).json({ error: reason });
}
}
@@ -298,22 +130,18 @@ export const router = express.Router();
let started = false;
export function startWebEndpointService(mountOn: Router): void {
export function startWebEndpointService(
mountOn: Router,
ctx: PluginContext,
): void {
current = ctx;
if (!started) {
// Built on first start rather than at module scope so importing this
// module for a handler unit test does not construct the auth singleton.
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
// Only "/open" here: the dispatcher this router is registered with is
// mounted at /ssh/tunnel/web-endpoint, so the full path stays
// /ssh/tunnel/web-endpoint/open exactly as before. The "/ssh" prefix is
// part of the path the tunnel service serves -- nginx proxies /ssh/tunnel/
// through with the path intact.
router.post("/open", authenticateJWT, handleWebEndpointOpen);
router.post("/open", handleWebEndpointOpen);
started = true;
}
mountOn.use(router);
}
export function stopWebEndpointService(): void {
// Unmounted by the runtime when the plugin deactivates.
current = null;
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+7 -1
View File
@@ -12,7 +12,10 @@ import type { Host } from "@/types/ui-types";
import type { WebEndpoint } from "@/types";
import { WebEndpointTab } from "./WebEndpointTab";
import { HostWebUiTab } from "./HostWebUiTab";
import { openWebEndpointExternally } from "./web-endpoint-api";
import {
openWebEndpointExternally,
setWebEndpointApi,
} from "./web-endpoint-api";
const TAB_TYPE = "web-endpoint";
@@ -73,6 +76,9 @@ function WebUiSection({ form, setField, protocols }: HostEditorSectionProps) {
}
export function activate(app: TermixApp): void {
setWebEndpointApi(app.api);
app.onDispose(() => setWebEndpointApi(null));
app.registerTab(TAB_TYPE, EndpointTab, {
icon: Globe,
titleKey: "hosts.tabWebUi",
@@ -1,9 +1,17 @@
import { currentTunnelHost, resolveWebEndpointUrl } from "./web-endpoint-url";
import axios from "axios";
import { handleApiError, tunnelApi } from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { handleApiError } from "@/main-axios";
import { isElectron } from "@/lib/electron";
import type { WebEndpoint } from "@/types/index";
let pluginApi: PluginApiClient | null = null;
/** Set from activate with app.api, cleared on deactivate. */
export function setWebEndpointApi(api: PluginApiClient | null): void {
pluginApi = api;
}
/**
* Thrown when the backend rejects a web endpoint tunnel open with a specific,
* actionable reason -- most commonly a 502 carrying the real cause: SSH auth
@@ -32,10 +40,9 @@ export async function openWebEndpointTunnel(
endpointId: string,
): Promise<number> {
try {
// Relative to the tunnel API base, which already includes /ssh
// (getApiUrl("/ssh", 30003)). A path beginning "/ssh" here would resolve
// to /ssh/ssh/... and 404 on every call.
const response = await tunnelApi.post("/tunnel/web-endpoint/open", {
if (!pluginApi) throw new Error("The web endpoint plugin is not active");
// Relative to the plugin mount, /plugin-api/web-endpoint.
const response = await pluginApi.post("/open", {
hostId,
endpointId,
});
+55 -178
View File
@@ -1,29 +1,14 @@
import type express from "express";
import express from "express";
import { readFile } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFakeContext } from "@termix/plugin-sdk/testing";
const resolveHostById = vi.hoisted(() => vi.fn());
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById,
}));
const manager = vi.hoisted(() => ({
activeTunnelRuntimes: new Map<string, unknown>(),
connectionStatus: new Map<string, unknown>(),
tunnelConnecting: new Set<string>(),
connectSSHTunnel: vi.fn(),
cleanupTunnelResources: vi.fn(async () => undefined),
}));
vi.mock("../../../../src/backend/hosts/tunnel/manager.js", () => manager);
const forwardOut = vi.hoisted(() => vi.fn(async () => ({ end: vi.fn() })));
vi.mock("../../../../src/backend/hosts/tunnel/ssh-primitives.js", () => ({
forwardOut,
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
const forward = vi.fn();
function response() {
const res = {
@@ -44,8 +29,8 @@ function response() {
};
}
function request(body: unknown, userId: string | undefined = "u1") {
return { body, userId } as unknown as express.Request;
function request(body: unknown) {
return { body } as unknown as express.Request;
}
const endpoint = (overrides: Record<string, unknown> = {}) => ({
@@ -67,34 +52,29 @@ function host(overrides: Record<string, unknown> = {}) {
port: 22,
username: "root",
authType: "password",
password: "pw",
enableWebUi: true,
webUiConfig: JSON.stringify({ endpoints: [endpoint()] }),
...overrides,
};
}
/** Makes connectSSHTunnel behave like a successful open. */
function tunnelConnectsOn(port: number) {
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.activeTunnelRuntimes.set(config.name, {
bindPort: port,
sourceClient: {},
close: vi.fn(),
});
},
);
/** Activates the routes against a fake ctx, with or without the tunnels service. */
async function load(options: { tunnels?: boolean; actor?: string } = {}) {
const fake = createFakeContext({
pluginId: "web-endpoint",
actor: options.actor ?? "u1",
});
if (options.tunnels !== false) {
fake.ctx.services.provide("tunnels.access", { forward });
}
const routes = await import("../../src/backend/routes.js");
routes.startWebEndpointService(express.Router(), fake.ctx);
return routes;
}
beforeEach(() => {
manager.activeTunnelRuntimes.clear();
manager.connectionStatus.clear();
manager.tunnelConnecting.clear();
manager.connectSSHTunnel.mockReset();
manager.cleanupTunnelResources.mockClear();
forwardOut.mockClear();
forwardOut.mockResolvedValue({ end: vi.fn() });
forward.mockReset();
forward.mockResolvedValue({ bindHost: "127.0.0.1", bindPort: 41234 });
resolveHostById.mockReset();
resolveHostById.mockResolvedValue(host());
});
@@ -103,34 +83,29 @@ afterEach(() => {
vi.resetModules();
});
describe("POST /tunnel/web-endpoint/open", () => {
describe("POST /open", () => {
it("refuses a host the user cannot resolve, without opening anything", async () => {
resolveHostById.mockResolvedValue(null);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(403);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
expect(forward).not.toHaveBeenCalled();
});
it("refuses when the feature is disabled even though the config still lists the endpoint", async () => {
// Reachable via a bulk update that sends only { webUiConfig } while the
// stored enable_web_ui is still 0. The UI reads as off in that state.
resolveHostById.mockResolvedValue(host({ enableWebUi: false }));
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(400);
expect(manager.connectSSHTunnel).not.toHaveBeenCalled();
expect(forward).not.toHaveBeenCalled();
});
it("refuses an endpoint id that matches nothing", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "nope" }),
@@ -139,10 +114,8 @@ describe("POST /tunnel/web-endpoint/open", () => {
expect(res.statusCode).toBe(400);
});
it("returns the real bound port", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("returns the port the tunnels service bound", async () => {
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
@@ -150,41 +123,26 @@ describe("POST /tunnel/web-endpoint/open", () => {
expect(res.body.port).toBe(41234);
});
it("selects the local-forward path via endpointHost, not targetHost", async () => {
// connectSSHTunnel routes on isSingleHostTunnel, which keys on
// endpointHost === "127.0.0.1". Setting only targetHost would silently
// select a different strategy and the forward would never bind locally.
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("asks for a loopback forward under the reserved web name", async () => {
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[0][0];
expect(config.endpointHost).toBe("127.0.0.1");
expect(config.mode).toBe("local");
expect(config.endpointPort).toBe(8006);
// Carries the source SSH identity -- connectSSHTunnel builds its client
// from these, and reads sourceUserId specifically.
expect(config.sourceIP).toBe("10.0.0.5");
expect(config.sourceUserId).toBe("u1");
expect(forward).toHaveBeenCalledWith(
7,
{
targetHost: "127.0.0.1",
targetPort: 8006,
bindHost: "127.0.0.1",
bindPort: undefined,
},
{ name: "web:7:e1", idleTimeoutMs: 10 * 60 * 1000 },
);
});
it("binds loopback by default and the endpoint's choice when given", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
expect(manager.connectSSHTunnel.mock.calls[0][0].bindHost).toBe(
"127.0.0.1",
);
manager.activeTunnelRuntimes.clear();
it("passes the endpoint's own bind address and fixed port through", async () => {
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({
@@ -192,105 +150,39 @@ describe("POST /tunnel/web-endpoint/open", () => {
}),
}),
);
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
const config = manager.connectSSHTunnel.mock.calls[1][0];
expect(config.bindHost).toBe("0.0.0.0");
expect(config.sourcePort).toBe(38080);
});
it("reuses a live tunnel instead of opening a second one", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const first = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
first,
);
const second = response();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
second,
);
expect(second.body.port).toBe(41234);
expect(manager.connectSSHTunnel).toHaveBeenCalledTimes(1);
});
it("reopens when the endpoint's target changed under a live tunnel", async () => {
tunnelConnectsOn(41234);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
await handleWebEndpointOpen(
request({ hostId: 7, endpointId: "e1" }),
response(),
);
// Same id, different port: the live forward now points at the wrong place.
resolveHostById.mockResolvedValue(
host({
webUiConfig: JSON.stringify({ endpoints: [endpoint({ port: 9000 })] }),
}),
);
tunnelConnectsOn(51234);
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
expect(res.body.port).toBe(51234);
const target = forward.mock.calls[0][1];
expect(target.bindHost).toBe("0.0.0.0");
expect(target.bindPort).toBe(38080);
});
it("reports the target port being closed rather than returning a dead 200", async () => {
// The likeliest real error: the listener binds fine, but nothing is
// serving on the endpoint's port. Without the probe this returns 200 and
// the user gets a blank frame with no message.
tunnelConnectsOn(41234);
forwardOut.mockRejectedValue(
it("reports the service's reason as a 502 instead of a dead 200", async () => {
forward.mockRejectedValue(
new Error("Channel open failure: connect failed"),
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/connect failed/);
// Nothing half-built is left registered.
expect(manager.cleanupTunnelResources).toHaveBeenCalledWith(
"web:7:e1",
true,
);
});
it("surfaces a connection failure reported only over the status map", async () => {
// connectSSHTunnel never rejects and resolves before the SSH handshake, so
// without waitForTunnelSettled this path would read as success.
manager.connectSSHTunnel.mockImplementation(
async (config: { name: string }) => {
manager.connectionStatus.set(config.name, {
connected: false,
reason: "All authentication methods failed",
});
},
);
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
it("answers 503 while the tunnels plugin is not available", async () => {
const { handleWebEndpointOpen } = await load({ tunnels: false });
const res = response();
await handleWebEndpointOpen(request({ hostId: 7, endpointId: "e1" }), res);
expect(res.statusCode).toBe(502);
expect(String(res.body.error)).toMatch(/authentication methods failed/);
expect(res.statusCode).toBe(503);
});
it("rejects a malformed request before touching the database", async () => {
const { handleWebEndpointOpen } =
await import("../../src/backend/routes.js");
const { handleWebEndpointOpen } = await load();
for (const body of [
{},
{ hostId: "7", endpointId: "e1" },
@@ -305,27 +197,13 @@ describe("POST /tunnel/web-endpoint/open", () => {
});
/**
* The client path and the server path have to agree.
*
* This guards a real bug: the route once served the unprefixed form while the
* client called the full one, so every call 404d and no handler unit test
* noticed, because those call the handler directly. It was caught only by
* opening a tunnel against a deployed build.
*
* The shape changed with A4. The route used to be composed of a mount point in
* the tunnel service (/ssh/tunnel/web-endpoint, port 30003) plus "/open" on
* this router. Now core mounts every plugin at /plugin-api/<id>, so the client
* calls /plugin-api/web-endpoint/open and the only half this plugin owns is
* "/open". Asserting that keeps the guard meaningful without re-encoding a
* prefix the plugin no longer controls.
* The client path and the server path have to agree. Core mounts every
* plugin at /plugin-api/<id>, so the client calls /plugin-api/web-endpoint/open
* and the only half this plugin owns is "/open".
*/
describe("route registration", () => {
it("registers /open on the router core hands it", async () => {
const routes = await import("../../src/backend/routes.js");
const express = (await import("express")).default;
const mountOn = express.Router();
routes.startWebEndpointService(mountOn);
const routes = await load();
const paths = (
routes.router as unknown as {
@@ -344,7 +222,6 @@ describe("route registration", () => {
"utf8",
);
// ctx.http.router() is what puts this plugin at /plugin-api/web-endpoint.
expect(source).toContain("ctx.http.router()");
});
});
@@ -1,136 +0,0 @@
import net from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Exercises establishDirectTunnel's local branch against a REAL TCP listener.
* The two behaviours here are the ones a mocked test cannot show:
*
* - under sourcePort 0 the kernel assigns the port, so the runtime has to
* record what it actually bound rather than the 0 it was asked for; and
* - an idle tunnel closes itself and REMOVES its runtime entry, so nothing
* keeps handing out a port whose listener is gone.
*
* The SSH half is stubbed -- these behaviours live entirely in the listener
* and the runtime bookkeeping.
*/
const sshPrimitives = vi.hoisted(() => ({
forwardOut: vi.fn(async () => {
throw new Error("not used in these tests");
}),
pipeTunnelStreams: vi.fn(),
handleSocks5Connect: vi.fn(),
}));
vi.mock(
"../../../../src/backend/hosts/tunnel/ssh-primitives.js",
() => sshPrimitives,
);
function tunnelConfig(overrides: Record<string, unknown> = {}) {
return {
name: "web:7:e1",
scope: "s2s",
mode: "local",
tunnelType: "local",
bindHost: "127.0.0.1",
sourcePort: 0,
endpointHost: "127.0.0.1",
endpointPort: 9,
...overrides,
} as never;
}
const fakeClient = () => ({ on: vi.fn(), end: vi.fn() }) as never;
afterEach(() => {
vi.resetModules();
});
describe("establishDirectTunnel records the port it actually bound", () => {
it("reports the kernel-assigned port, never the requested 0", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(fakeClient(), tunnelConfig());
const runtime = manager.activeTunnelRuntimes.get("web:7:e1");
expect(runtime).toBeDefined();
// The whole point: 0 means "pick one for me", so a runtime advertising 0
// would make every caller that reuses the port fail.
expect(runtime?.bindPort).toBeGreaterThan(0);
// And it is genuinely listening there.
const port = runtime!.bindPort as number;
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.destroy();
resolve();
});
socket.once("error", reject);
});
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:e1");
});
it("honours a fixed source port when one is asked for", async () => {
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
// Borrow a free port, release it, then ask for it explicitly.
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()));
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:fixed", sourcePort: wanted }),
);
const runtime = manager.activeTunnelRuntimes.get("web:7:fixed");
expect(runtime?.bindPort).toBe(wanted);
runtime?.close();
manager.activeTunnelRuntimes.delete("web:7:fixed");
});
});
describe("idle close", () => {
it("removes the runtime entry, not just the listener", async () => {
// Real timers deliberately: establishDirectTunnel awaits real socket I/O,
// and mixing fake timers with that is a known route to a hanging test.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:idle", idleTimeoutMs: 60 }),
);
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(true);
await vi.waitFor(
() => {
// Deleting the entry is the assertion. close() alone would stop the
// listener but leave the entry behind, so the open route would keep
// returning a port nothing is listening on.
expect(manager.activeTunnelRuntimes.has("web:7:idle")).toBe(false);
},
{ timeout: 4000, interval: 25 },
);
});
it("leaves a tunnel with no idle timeout running", async () => {
// Without this, an idle-close bug that fires unconditionally would still
// pass the test above.
const manager =
await import("../../../../src/backend/hosts/tunnel/manager.js");
await manager.establishDirectTunnel(
fakeClient(),
tunnelConfig({ name: "web:7:forever" }),
);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(manager.activeTunnelRuntimes.has("web:7:forever")).toBe(true);
manager.activeTunnelRuntimes.get("web:7:forever")?.close();
manager.activeTunnelRuntimes.delete("web:7:forever");
});
});
@@ -6,7 +6,6 @@ vi.mock("@/lib/electron", () => ({ isElectron }));
const tunnelPost = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
tunnelApi: { post: tunnelPost },
handleApiError: (error: unknown) => {
throw new Error(`generic: ${String(error)}`);
},
@@ -32,7 +31,10 @@ let pageHostname = "localhost";
const realLocation = window.location;
const windowOpen = vi.fn();
beforeEach(() => {
beforeEach(async () => {
const { setWebEndpointApi } =
await import("../../src/frontend/web-endpoint-api");
setWebEndpointApi({ post: tunnelPost } as never);
pageHostname = "localhost";
isElectron.mockReturnValue(false);
tunnelPost.mockReset();
@@ -54,17 +56,14 @@ afterEach(() => {
});
describe("openWebEndpointTunnel", () => {
it("posts a path relative to the tunnel base, never one starting /ssh", async () => {
// tunnelApi's baseURL already includes /ssh; a leading "/ssh" here would
// resolve to /ssh/ssh/... and 404 on every call. Asserted on the captured
// runtime argument rather than by scanning source, so quoting style and
// indirection cannot fool it.
it("posts to the plugin's own /open route", async () => {
// app.api is already rooted at /plugin-api/web-endpoint, so the path is
// relative to that and must not repeat the prefix.
const { openWebEndpointTunnel } =
await import("../../src/frontend/web-endpoint-api");
await openWebEndpointTunnel(7, "e1");
expect(tunnelPost.mock.calls[0][0]).not.toMatch(/^\/ssh\//);
expect(tunnelPost).toHaveBeenCalledWith("/tunnel/web-endpoint/open", {
expect(tunnelPost).toHaveBeenCalledWith("/open", {
hostId: 7,
endpointId: "e1",
});