Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/quiet-tunnels-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opentunnel": minor
---

Add tunnel status inspection to the client and an `opentunnel status` command that reports every local profile.
3 changes: 3 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ opentunnel info
opentunnel route add api 127.0.0.1:3000
opentunnel route add admin 127.0.0.1:4000
opentunnel route list
opentunnel status
opentunnel serve
opentunnel service status
opentunnel service restart
Expand All @@ -34,6 +35,8 @@ Each profile owns one tunnel identity and set of subdomain-to-process routes.
`opentunnel info` reads the selected profile's existing local identity and shows
its tunnel ID, hostname and URL, certificate expiry, and configured route count.
It does not create a tunnel when the profile has no identity.
`opentunnel status` shows every locally stored profile with its hostname, local
service state, remote tunnel state, and configured route count.
Path routing is intentionally not supported. Every command ensures a background
service is running for the selected profile. Configuration changes signal that
process to reload and reconnect. `opentunnel serve` is a blocking command that
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
serviceStatus,
stopService,
} from "./service.js";
import { formatStatus, type StatusRow } from "./status.js";

const root = Command.make("opentunnel").pipe(
Command.withDescription("Create and manage blind TLS tunnels"),
Expand Down Expand Up @@ -190,6 +191,44 @@ const service = Command.make("service").pipe(
Command.withSubcommands([serviceStatusCommand, serviceStart, serviceStop, serviceRestart]),
);

const status = Command.make(
"status",
{},
Effect.fn(function* () {
const client = yield* OpenTunnelClient;
const profiles = yield* client.profile.list();
const rows = yield* Effect.forEach(
profiles,
(profile) => Effect.gen(function* () {
const tunnel = yield* client.tunnel.get({ profile });
const pending = tunnel ? undefined : yield* client.tunnel.pending({ profile });
const config = yield* loadOpenTunnelConfig(profile);
const running = yield* serviceStatus(profile);
const state = yield* client.tunnel.status({ profile }).pipe(
Effect.catch(() => Effect.succeed("unreachable" as const)),
);
return {
profile,
hostname: tunnel?.hostname ?? pending?.hostname ?? "—",
service: running ? "running" : "stopped",
tunnel: state ?? "error",
routes: Object.keys(config.routes).length,
} satisfies StatusRow;
}).pipe(
Effect.catch(() => Effect.succeed({
profile,
hostname: "—",
service: "stopped",
tunnel: "error",
routes: "—",
} satisfies StatusRow)),
),
{ concurrency: 4 },
);
yield* Console.log(formatStatus(rows));
}),
).pipe(Command.withDescription("Show all local tunnel profiles and their status"));

const routeAdd = Command.make(
"add",
{
Expand Down Expand Up @@ -250,7 +289,7 @@ const route = Command.make("route").pipe(
Command.withSubcommands([routeAdd, routeRemove, routeList]),
);

export const cli = root.pipe(Command.withSubcommands([create, serve, service, info, route]));
export const cli = root.pipe(Command.withSubcommands([create, serve, service, status, info, route]));

Command.run(cli, { version: "0.0.0" }).pipe(
Effect.provide(OpenTunnelClient.layer()),
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import { formatStatus } from "./status.js";

describe("formatStatus", () => {
test("formats profiles as an aligned table", () => {
expect(formatStatus([
{
profile: "default",
hostname: "vogel.opentunnel.xyz",
service: "running",
tunnel: "online",
routes: 3,
},
{
profile: "demo",
hostname: "demo.opentunnel.xyz",
service: "stopped",
tunnel: "offline",
routes: 1,
},
])).toBe([
"PROFILE HOSTNAME SERVICE TUNNEL ROUTES",
"default vogel.opentunnel.xyz running online 3",
"demo demo.opentunnel.xyz stopped offline 1",
].join("\n"));
});

test("describes an empty profile list", () => {
expect(formatStatus([])).toBe("No tunnel profiles found.");
});
});
30 changes: 30 additions & 0 deletions packages/cli/src/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface StatusRow {
readonly profile: string;
readonly hostname: string;
readonly service: "running" | "stopped";
readonly tunnel: "online" | "offline" | "pending" | "unreachable" | "error";
readonly routes: number | string;
}

export function formatStatus(rows: ReadonlyArray<StatusRow>): string {
if (rows.length === 0) return "No tunnel profiles found.";

const values = [
["PROFILE", "HOSTNAME", "SERVICE", "TUNNEL", "ROUTES"],
...rows.map((row) => [
row.profile,
row.hostname,
row.service,
row.tunnel,
String(row.routes),
]),
];
const widths = values[0]!.map((_, column) =>
Math.max(...values.map((row) => row[column]!.length))
);
return values
.map((row) => row.map((value, column) =>
column === row.length - 1 ? value : value.padEnd(widths[column]!)
).join(" "))
.join("\n");
}
1 change: 1 addition & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface Client {
}
readonly tunnel: {
get(options?: ProfileOptions): Promise<TunnelIdentity | undefined>
status(options?: ProfileOptions): Promise<"online" | "offline" | "pending" | undefined>
ensure(options?: ProfileOptions): Promise<TunnelIdentity>
remove(options?: ProfileOptions): Promise<void>
connect(options?: ConnectOptions): Promise<Connection>
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/effect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ export class OpenTunnelClient extends ServiceMap.Service<
return yield* storage.load(profileName(input));
});

const status = Effect.fn("OpenTunnelClient.tunnel.status")(function* (
input?: OpenTunnelProfileOptions,
) {
const profile = profileName(input);
const identity = yield* storage.load(profile);
if (!identity) return (yield* storage.loadPending(profile)) ? "pending" as const : undefined;
const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(identity.token));
const tunnel = yield* authorized.tunnel["tunnel.get"]({
params: { id: Tunnel.ID.makeUnsafe(identity.id) },
}).pipe(
Effect.mapError((cause) => clientError("Failed to read tunnel status", cause)),
);
return tunnel.state;
});

const completePending = Effect.fn("OpenTunnelClient.tunnel.completePending")(function* (options: {
readonly profile: string;
readonly pending: OpenTunnelPendingIdentity;
Expand Down Expand Up @@ -277,6 +292,7 @@ export class OpenTunnelClient extends ServiceMap.Service<
tunnel: {
list: storage.list,
get,
status,
pending,
resume,
create,
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/effect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface OpenTunnelStoredTunnel {
readonly tunnel: OpenTunnelIdentity;
}

export type OpenTunnelStatus = "offline" | "online" | "pending";

export type OpenTunnelClientEvent =
| { readonly type: "connected" }
| { readonly type: "disconnected"; readonly reason?: string }
Expand Down Expand Up @@ -78,6 +80,9 @@ export interface OpenTunnelEffectClient {
readonly get: (
options?: OpenTunnelProfileOptions,
) => Effect.Effect<OpenTunnelIdentity | undefined, OpenTunnelError>;
readonly status: (
options?: OpenTunnelProfileOptions,
) => Effect.Effect<OpenTunnelStatus | undefined, OpenTunnelError>;
readonly pending: (
options?: OpenTunnelProfileOptions,
) => Effect.Effect<Pick<OpenTunnelPendingIdentity, "id" | "hostname"> | undefined, OpenTunnelError>;
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/promise/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
OpenTunnelProfileOptions,
OpenTunnelProvisionStage,
OpenTunnelRoute,
OpenTunnelStatus,
OpenTunnelStoredTunnel,
} from "../effect/types.js";
import { toEffectStorage, type OpenTunnelStorage } from "./storage.js";
Expand Down Expand Up @@ -43,6 +44,7 @@ export interface OpenTunnelPromiseClient {
readonly tunnel: {
readonly list: () => Promise<ReadonlyArray<OpenTunnelStoredTunnel>>;
readonly get: (options?: OpenTunnelProfileOptions) => Promise<OpenTunnelIdentity | undefined>;
readonly status: (options?: OpenTunnelProfileOptions) => Promise<OpenTunnelStatus | undefined>;
readonly pending: (
options?: OpenTunnelProfileOptions,
) => Promise<Pick<OpenTunnelPendingIdentity, "id" | "hostname"> | undefined>;
Expand Down Expand Up @@ -88,6 +90,7 @@ export function create(options: OpenTunnelClientOptions = {}): OpenTunnelPromise
tunnel: {
list: () => withClient((client) => client.tunnel.list()),
get: (input) => withClient((client) => client.tunnel.get(input)),
status: (input) => withClient((client) => client.tunnel.status(input)),
pending: (input) => withClient((client) => client.tunnel.pending(input)),
resume: (input) => withClient((client) => client.tunnel.resume(input)),
create: (input) => withClient((client) => client.tunnel.create(input)),
Expand Down
Loading