Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 61 additions & 13 deletions src/application-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ export interface TailscaleSshProvider {
port?: 22;
}

export interface LiskovSshProvider {
authorizedKeys: string[];
kind: "liskov";
}

export type RuntimeSshIngressPolicy =
| { mode: "disabled" }
| { mode: "optional" }
| { mode: "required"; provider: TailscaleSshProvider };
| { mode: "required"; provider: LiskovSshProvider | TailscaleSshProvider };

function object(value: unknown): JsonObject | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
Expand Down Expand Up @@ -167,6 +172,46 @@ function checkDuplicateStrings(value: unknown, at: string, errors: PolicyValidat
});
}

function checkManagedAuthorizedKeys(value: unknown, errors: PolicyValidationError[]): void {
const at = "/ingress/ssh/provider/authorizedKeys";
if (!Array.isArray(value) || value.length < 1 || value.length > 8) {
errors.push({ code: "invalid_manifest", message: "must contain one to eight Ed25519 public keys", pointer: at });
return;
}
const fingerprints = new Set<string>();
value.forEach((key, index) => {
const pointer = `${at}/${index}`;
if (typeof key !== "string" || key.trim() !== key || key.split(" ").length !== 2) {
errors.push({ code: "invalid_manifest", message: "must be normalized ssh-ed25519 key material without options or comments", pointer });
return;
}
const [algorithm, encoded] = key.split(" ");
let blob: Buffer;
try {
blob = Buffer.from(encoded, "base64");
} catch {
errors.push({ code: "invalid_manifest", message: "must be a canonical Ed25519 SSH public key", pointer });
return;
}
if (
algorithm !== "ssh-ed25519"
|| blob.length !== 51
|| blob.readUInt32BE(0) !== 11
|| blob.subarray(4, 15).toString("ascii") !== "ssh-ed25519"
|| blob.readUInt32BE(15) !== 32
|| blob.toString("base64") !== encoded
) {
errors.push({ code: "invalid_manifest", message: "must be a canonical Ed25519 SSH public key", pointer });
return;
}
const fingerprint = createHash("sha256").update(blob).digest("base64url");
if (fingerprints.has(fingerprint)) {
errors.push({ code: "invalid_manifest", message: "authorized key fingerprints must be unique", pointer });
}
fingerprints.add(fingerprint);
});
}

function checkDuplicateKeys(
values: JsonObject[],
key: string,
Expand Down Expand Up @@ -596,23 +641,26 @@ export function validateApplicationManifestV4(value: unknown): PolicyValidationE
checkObject(ingress.ssh, "/ingress/ssh", ["mode"], errors, ["mode"]);
} else if (ingress.ssh !== undefined && ssh.mode === "required") {
checkObject(ingress.ssh, "/ingress/ssh", ["mode", "provider"], errors, ["mode", "provider"]);
const provider = checkObject(ssh.provider, "/ingress/ssh/provider", ["kind", "integrationId", "port"], errors, ["kind", "integrationId"]);
checkEnum(provider.kind, ["tailscale"], "/ingress/ssh/provider/kind", errors);
checkNonEmptyString(provider.integrationId, "/ingress/ssh/provider/integrationId", errors);
if (provider.port !== undefined && provider.port !== 22) {
errors.push({
code: "invalid_manifest",
message: "Tailscale Runtime SSH port must be 22",
pointer: "/ingress/ssh/provider/port"
});
const rawProvider = object(ssh.provider);
if (rawProvider?.kind === "liskov") {
const provider = checkObject(ssh.provider, "/ingress/ssh/provider", ["kind", "authorizedKeys"], errors, ["kind", "authorizedKeys"]);
checkManagedAuthorizedKeys(provider.authorizedKeys, errors);
} else {
const provider = checkObject(ssh.provider, "/ingress/ssh/provider", ["kind", "integrationId", "port"], errors, ["kind", "integrationId"]);
checkEnum(provider.kind, ["tailscale"], "/ingress/ssh/provider/kind", errors);
checkNonEmptyString(provider.integrationId, "/ingress/ssh/provider/integrationId", errors);
if (provider.port !== undefined && provider.port !== 22) {
errors.push({
code: "invalid_manifest",
message: "Tailscale Runtime SSH port must be 22",
pointer: "/ingress/ssh/provider/port"
});
}
}
}
if (http.mode === "optional" || ssh.mode === "optional") {
errors.push({ code: "unsupported_policy_feature", message: "optional ingress is not enabled", pointer: "/ingress" });
}
if (ingress.http !== undefined && ingress.ssh !== undefined) {
errors.push({ code: "unsupported_policy_feature", message: "simultaneous HTTP and SSH ingress is not enabled", pointer: "/ingress" });
}
const observability = checkOptionalObject(root, "observability", "", ["logs", "runtimeDiagnostics"], errors);
const logs = checkOptionalObject(observability, "logs", "/observability", ["enabled", "profileId", "sinkName", "context"], errors);
if (logs.enabled !== undefined && typeof logs.enabled !== "boolean") {
Expand Down
9 changes: 7 additions & 2 deletions src/commands/liskov/ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,34 @@ import { runRuntimeSshConnection } from "../../runtime-ssh.js";

export default class LiskovSsh extends OrganizationScopedCommand {
static args = { app: Args.string({ description: "Liskov Application uid, name, or legacy id.", required: true }) };
static description = "Connect to an exact ready runtime through your existing Tailscale login.";
static description = "Connect to an exact ready runtime through its declared Runtime SSH provider.";
static examples = [
"<%= config.bin %> liskov ssh my-app",
"<%= config.bin %> liskov ssh my-app --deployment deploy_123 --print-command",
"<%= config.bin %> liskov ssh my-app --job job_123"
];
static flags: Interfaces.FlagInput = {
"accept-host-key": Flags.boolean({ description: "Accept and pin a first-use managed runtime host key without prompting." }),
config: Flags.string({ description: "Path to the local Liskov session file." }),
deployment: Flags.string({ description: "Select an exact deployment id." }),
help: Flags.help({ char: "h" }),
identity: Flags.string({ description: "Customer-owned Ed25519 private key for managed Runtime SSH." }),
job: Flags.string({ description: "Select an exact job id." }),
json: Flags.boolean({ description: "Emit machine-readable JSON (most useful with --print-command)." }),
"print-command": Flags.boolean({ description: "Resolve and verify the connection without opening SSH." }),
"slipway-url": Flags.string({ description: "Liskov service URL." })
};
static summary = "Open BYO Tailscale SSH to a Liskov runtime.";
static summary = "Open SSH to a Liskov runtime through Tailscale or managed access.";

async run(): Promise<void> {
const { args, flags } = await this.parse(LiskovSsh);
const code = await runRuntimeSshConnection({
applicationRef: args.app,
acceptHostKey: flags["accept-host-key"] as boolean | undefined,
cliBin: this.config.bin,
deploymentId: flags.deployment as string | undefined,
jobId: flags.job as string | undefined,
identity: flags.identity as string | undefined,
printCommand: flags["print-command"] as boolean | undefined,
config: flags.config as string | undefined,
json: flags.json as boolean | undefined,
Expand Down
6 changes: 3 additions & 3 deletions src/managed-access-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import type { Readable, Writable } from "node:stream";

import WebSocket, { type RawData } from "ws";

export const ACCESS_SUBPROTOCOL = "liskov-access.v0";
export const ACCESS_SUBPROTOCOL = "liskov-access.v1";
export const MAX_ACCESS_FRAME_BYTES = 64 * 1024;
const MAX_TOKEN_BYTES = 1024;
const MAX_TOKEN_BYTES = 16 * 1024;

export class ManagedAccessProxyError extends Error {
constructor(public readonly code: string) {
Expand Down Expand Up @@ -61,7 +61,7 @@ export function validateTunnelId(value: string): string {
export function buildSessionEndpoint(gateway: string, tunnelId: string): string {
const origin = validateGatewayOrigin(gateway);
const id = validateTunnelId(tunnelId);
origin.pathname = `/v0/sessions/${id}`;
origin.pathname = `/v1/sessions/${id}`;
return origin.toString();
}

Expand Down
Loading