Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
148 lines
6.0 KiB
TypeScript
148 lines
6.0 KiB
TypeScript
import type { Host, PortForwardingRule } from './models';
|
|
import { getNextVaultOrder } from './vaultOrder';
|
|
|
|
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
type NewRuleValues = { id: string; now: number };
|
|
|
|
export const hasPortForwardingConnectionChanged = (
|
|
existing: PortForwardingRule,
|
|
updated: PortForwardingRule,
|
|
): boolean => (
|
|
existing.type !== updated.type
|
|
|| existing.localPort !== updated.localPort
|
|
|| existing.remoteHost !== updated.remoteHost
|
|
|| existing.remotePort !== updated.remotePort
|
|
|| existing.bindAddress !== updated.bindAddress
|
|
|| existing.hostId !== updated.hostId
|
|
);
|
|
|
|
const parsePort = (value: unknown, name: string): Result<number> => {
|
|
const parsed = typeof value === 'number' ? value : Number(String(value ?? ''));
|
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
|
|
? { ok: true, value: parsed }
|
|
: { ok: false, error: `${name} must be an integer between 1 and 65535.` };
|
|
};
|
|
|
|
export const validatePortForwardingHost = (
|
|
hosts: Host[],
|
|
hostId: string | undefined,
|
|
): Result<Host> => {
|
|
const host = hosts.find((candidate) => candidate.id === hostId);
|
|
if (!hostId || !host) {
|
|
return { ok: false, error: `Host "${hostId || ''}" was not found.` };
|
|
}
|
|
if (host.protocol && host.protocol !== 'ssh') {
|
|
return { ok: false, error: `Host "${hostId}" does not support port forwarding.` };
|
|
}
|
|
return { ok: true, value: host };
|
|
};
|
|
|
|
function buildRule(
|
|
source: Record<string, unknown>,
|
|
hosts: Host[],
|
|
existing?: PortForwardingRule,
|
|
newRule?: NewRuleValues,
|
|
): Result<PortForwardingRule> {
|
|
const type = source.type === undefined ? existing?.type : String(source.type).trim();
|
|
if (type !== 'local' && type !== 'remote' && type !== 'dynamic') {
|
|
return { ok: false, error: 'type must be local, remote, or dynamic.' };
|
|
}
|
|
const localPort = parsePort(source.localPort ?? existing?.localPort, 'localPort');
|
|
if ('error' in localPort) return { ok: false, error: localPort.error };
|
|
const hostId = source.hostId === undefined ? existing?.hostId : String(source.hostId).trim();
|
|
const validatedHost = validatePortForwardingHost(hosts, hostId);
|
|
if (!validatedHost.ok) return validatedHost;
|
|
const remoteHost = source.remoteHost === undefined ? existing?.remoteHost : String(source.remoteHost).trim();
|
|
let remotePort: number | undefined;
|
|
if (type !== 'dynamic') {
|
|
if (!remoteHost) return { ok: false, error: 'remoteHost is required for local and remote forwarding.' };
|
|
const parsedRemotePort = parsePort(source.remotePort ?? existing?.remotePort, 'remotePort');
|
|
if ('error' in parsedRemotePort) return { ok: false, error: parsedRemotePort.error };
|
|
remotePort = parsedRemotePort.value;
|
|
}
|
|
if (!existing && !newRule) return { ok: false, error: 'New rule id and timestamp are required.' };
|
|
let autoStart = existing?.autoStart ?? false;
|
|
if (source.autoStart !== undefined) {
|
|
if (typeof source.autoStart === 'boolean') autoStart = source.autoStart;
|
|
else {
|
|
const normalized = String(source.autoStart).trim().toLowerCase();
|
|
if (['true', '1', 'yes'].includes(normalized)) autoStart = true;
|
|
else if (['false', '0', 'no'].includes(normalized)) autoStart = false;
|
|
else return { ok: false, error: 'autoStart must be true or false.' };
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
id: existing?.id ?? newRule!.id,
|
|
label: String(source.label ?? existing?.label ?? `${type} ${localPort.value}`).trim() || `${type} ${localPort.value}`,
|
|
type,
|
|
localPort: localPort.value,
|
|
bindAddress: String(source.bindAddress ?? existing?.bindAddress ?? '127.0.0.1').trim() || '127.0.0.1',
|
|
...(type === 'dynamic' ? {} : { remoteHost, remotePort }),
|
|
hostId,
|
|
autoStart,
|
|
status: existing?.status ?? 'inactive',
|
|
error: existing?.error,
|
|
lastUsedAt: existing?.lastUsedAt,
|
|
createdAt: existing?.createdAt ?? newRule!.now,
|
|
order: existing?.order,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createPortForwardingRule(
|
|
rules: PortForwardingRule[],
|
|
hosts: Host[],
|
|
source: Record<string, unknown>,
|
|
newRule: NewRuleValues,
|
|
): Result<{ rules: PortForwardingRule[]; rule: PortForwardingRule }> {
|
|
const built = buildRule(source, hosts, undefined, newRule);
|
|
if ('error' in built) return { ok: false, error: built.error };
|
|
const rule = { ...built.value, order: getNextVaultOrder(rules) };
|
|
return { ok: true, value: { rules: [...rules, rule], rule } };
|
|
}
|
|
|
|
export function updatePortForwardingRule(
|
|
rules: PortForwardingRule[],
|
|
hosts: Host[],
|
|
ruleId: string,
|
|
source: Record<string, unknown>,
|
|
): Result<{ rules: PortForwardingRule[]; rule: PortForwardingRule }> {
|
|
const existing = rules.find((rule) => rule.id === ruleId);
|
|
if (!existing) return { ok: false, error: `Port forwarding rule "${ruleId}" was not found.` };
|
|
const built = buildRule(source, hosts, existing);
|
|
if ('error' in built) return { ok: false, error: built.error };
|
|
const connectionChanged = hasPortForwardingConnectionChanged(existing, built.value);
|
|
const updatedRule = connectionChanged
|
|
? { ...built.value, status: 'inactive' as const, error: undefined }
|
|
: built.value;
|
|
return {
|
|
ok: true,
|
|
value: { rules: rules.map((rule) => rule.id === ruleId ? updatedRule : rule), rule: updatedRule },
|
|
};
|
|
}
|
|
|
|
export function duplicatePortForwardingRule(
|
|
rules: PortForwardingRule[],
|
|
hosts: Host[],
|
|
ruleId: string,
|
|
newRule: NewRuleValues,
|
|
): Result<{ rules: PortForwardingRule[]; rule: PortForwardingRule }> {
|
|
const existing = rules.find((rule) => rule.id === ruleId);
|
|
if (!existing) return { ok: false, error: `Port forwarding rule "${ruleId}" was not found.` };
|
|
const validated = buildRule({}, hosts, existing);
|
|
if ('error' in validated) return { ok: false, error: validated.error };
|
|
const rule: PortForwardingRule = {
|
|
...validated.value,
|
|
id: newRule.id,
|
|
label: `${existing.label} (Copy)`,
|
|
status: 'inactive',
|
|
error: undefined,
|
|
lastUsedAt: undefined,
|
|
createdAt: newRule.now,
|
|
order: getNextVaultOrder(rules),
|
|
};
|
|
return { ok: true, value: { rules: [...rules, rule], rule } };
|
|
}
|