Skip to content
Closed
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
152 changes: 152 additions & 0 deletions src/reachability/kubo-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// The only code that decides what `bitsocial reachability` changes in the kubo config. Everything
// here is pure so the disable/enable byte-for-byte round trip can be tested as a property.

// The `server` kubo profile adds this filter, and its connection gater then rejects every connection
// whose remote address is loopback, which is exactly what a local TLS terminator produces.
export const LOOPBACK_ADDR_FILTER = "/ip4/127.0.0.0/ipcidr/8";

export const WEBTRANSPORT_SWARM_ADDRESSES = [
"/ip4/0.0.0.0/udp/443/quic-v1",
"/ip4/0.0.0.0/udp/443/quic-v1/webtransport",
"/ip6/::/udp/443/quic-v1",
"/ip6/::/udp/443/quic-v1/webtransport"
] as const;

export const localWsSwarmAddress = (wsPort: number) => `/ip4/127.0.0.1/tcp/${wsPort}/ws`;

// A field's shape before we touched it. kubo writes `null` and `[]` for empty lists depending on
// version and profile, and restoring the wrong one would break the byte-exact round trip.
type OriginalShape = "absent" | "null" | "present";

// What the last apply changed. Stored in reachability/config.json; only this tells our entries apart
// from identical ones the operator wrote, and names the old addresses to remove after an IP change.
export interface KuboEditRecord {
addressesShape: OriginalShape;
swarmListShape: OriginalShape;
appendAnnounceListShape: OriginalShape;
addedSwarm: string[];
addedAppendAnnounce: string[];
removedAddrFilters: { entry: string; index: number }[];
autoTls: { sectionShape: OriginalShape; enabledShape: OriginalShape; enabled?: unknown };
}

export interface ReachabilityKuboInput {
wsPort: number;
announce: string[];
kuboEdits?: KuboEditRecord;
}

const shapeOf = (value: unknown): OriginalShape => (value === undefined ? "absent" : value === null ? "null" : "present");

const withoutEntries = (list: unknown, entries: string[]) => (Array.isArray(list) ? list.filter((entry) => !entries.includes(entry)) : list);

// Puts an emptied list or section back the way we found it. A list the operator has since added to stays.
const restoreShape = (parent: any, key: string, shape: OriginalShape) => {
const value = parent[key];
const isEmpty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
if (!isEmpty || shape === "present") return;
if (shape === "absent") delete parent[key];
else parent[key] = null;
};

function undoListEdits(config: any, edits: KuboEditRecord) {
if (config.Addresses) {
config.Addresses.Swarm = withoutEntries(config.Addresses.Swarm, edits.addedSwarm);
config.Addresses.AppendAnnounce = withoutEntries(config.Addresses.AppendAnnounce, edits.addedAppendAnnounce);
restoreShape(config.Addresses, "Swarm", edits.swarmListShape);
restoreShape(config.Addresses, "AppendAnnounce", edits.appendAnnounceListShape);
restoreShape(config, "Addresses", edits.addressesShape);
}
if (edits.removedAddrFilters.length > 0 && config.Swarm) {
const restored = edits.removedAddrFilters.map(({ entry }) => entry);
const filters: unknown[] = Array.isArray(config.Swarm.AddrFilters) ? withoutEntries(config.Swarm.AddrFilters, restored) as unknown[] : [];
for (const { entry, index } of edits.removedAddrFilters) filters.splice(Math.min(index, filters.length), 0, entry);
config.Swarm.AddrFilters = filters;
}
}

// Runs only on disable. If AutoTLS is no longer false the operator flipped it by hand, and we leave
// their choice alone.
function restoreAutoTls(config: any, autoTls: KuboEditRecord["autoTls"]) {
if (config.AutoTLS?.Enabled !== false) return;
if (autoTls.enabledShape === "absent") delete config.AutoTLS.Enabled;
else config.AutoTLS.Enabled = autoTls.enabled;
restoreShape(config, "AutoTLS", autoTls.sectionShape);
}

const appendMissing = (existing: string[], entries: string[]) => {
const added: string[] = [];
for (const entry of entries) {
if (existing.includes(entry) || added.includes(entry)) continue;
added.push(entry);
}
existing.push(...added);
return added;
};

// Returns the new kubo config and the edit record the caller must persist with it. Applying is always
// undo-then-redo against the operator's baseline, so repeated applies and IP changes cannot pile up.
export function planKuboConfig(
currentKuboConfig: any,
reachabilityConfig: ReachabilityKuboInput,
enabled: boolean
): { kuboConfig: any; kuboEdits: KuboEditRecord | undefined } {
const config = structuredClone(currentKuboConfig ?? {});
const previousEdits = reachabilityConfig.kuboEdits;
if (previousEdits) undoListEdits(config, previousEdits);

if (!enabled) {
if (previousEdits) restoreAutoTls(config, previousEdits.autoTls);
return { kuboConfig: config, kuboEdits: undefined };
}

const addressesShape = previousEdits?.addressesShape ?? shapeOf(config.Addresses);
config.Addresses ??= {};
const swarmListShape = previousEdits?.swarmListShape ?? shapeOf(config.Addresses.Swarm);
const appendAnnounceListShape = previousEdits?.appendAnnounceListShape ?? shapeOf(config.Addresses.AppendAnnounce);
config.Addresses.Swarm ??= [];
config.Addresses.AppendAnnounce ??= [];

const addedSwarm = appendMissing(config.Addresses.Swarm, [localWsSwarmAddress(reachabilityConfig.wsPort), ...WEBTRANSPORT_SWARM_ADDRESSES]);
const addedAppendAnnounce = appendMissing(config.Addresses.AppendAnnounce, reachabilityConfig.announce);

const removedAddrFilters: KuboEditRecord["removedAddrFilters"] = [];
if (Array.isArray(config.Swarm?.AddrFilters)) {
config.Swarm.AddrFilters.forEach((entry: unknown, index: number) => {
if (entry === LOOPBACK_ADDR_FILTER) removedAddrFilters.push({ entry, index });
});
config.Swarm.AddrFilters = withoutEntries(config.Swarm.AddrFilters, [LOOPBACK_ADDR_FILTER]);
}

// Set once per enable. A later repair or IP change must not undo an operator flipping it back on.
let autoTls = previousEdits?.autoTls;
if (!autoTls) {
autoTls = {
sectionShape: shapeOf(config.AutoTLS),
enabledShape: shapeOf(config.AutoTLS?.Enabled),
...(config.AutoTLS?.Enabled !== undefined ? { enabled: config.AutoTLS.Enabled } : {})
};
config.AutoTLS = { ...(config.AutoTLS ?? {}), Enabled: false };
}

return {
kuboConfig: config,
kuboEdits: {
addressesShape,
swarmListShape,
appendAnnounceListShape,
addedSwarm,
addedAppendAnnounce,
removedAddrFilters,
autoTls
}
};
}

export function computeKuboConfig(currentKuboConfig: any, reachabilityConfig: ReachabilityKuboInput, enabled: boolean): any {
return planKuboConfig(currentKuboConfig, reachabilityConfig, enabled).kuboConfig;
}
198 changes: 198 additions & 0 deletions test/reachability/kubo-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import {
computeKuboConfig,
LOOPBACK_ADDR_FILTER,
localWsSwarmAddress,
planKuboConfig,
WEBTRANSPORT_SWARM_ADDRESSES,
type ReachabilityKuboInput
} from "../../src/reachability/kubo-config.js";

const DEFAULT_SWARM = [
"/ip4/0.0.0.0/tcp/4001",
"/ip6/::/tcp/4001",
"/ip4/0.0.0.0/udp/4001/webrtc-direct",
"/ip4/0.0.0.0/udp/4001/quic-v1",
"/ip4/0.0.0.0/udp/4001/quic-v1/webtransport",
"/ip6/::/udp/4001/webrtc-direct",
"/ip6/::/udp/4001/quic-v1",
"/ip6/::/udp/4001/quic-v1/webtransport"
];
const SERVER_ADDR_FILTERS = ["/ip4/10.0.0.0/ipcidr/8", "/ip4/100.64.0.0/ipcidr/10", LOOPBACK_ADDR_FILTER, "/ip4/169.254.0.0/ipcidr/16"];
const ANNOUNCE_V4 = "/ip4/203.0.113.7/tcp/443/tls/ws";
const ANNOUNCE_V6 = "/ip6/2001:db8::7/tcp/443/tls/ws";
const reachability = (overrides: Partial<ReachabilityKuboInput> = {}): ReachabilityKuboInput => ({
wsPort: 4002,
announce: [ANNOUNCE_V4, ANNOUNCE_V6],
...overrides
});
const serverProfileConfig = () => ({
Identity: { PeerID: "peer" },
Addresses: { API: "/ip4/127.0.0.1/tcp/50019", Swarm: [...DEFAULT_SWARM], Announce: [], AppendAnnounce: [], NoAnnounce: [] },
Swarm: { AddrFilters: [...SERVER_ADDR_FILTERS], DisableNatPortMap: true },
AutoTLS: { Enabled: true }
});

// One enable, then one disable, as the commands would run them: each persists the returned record.
const enable = (kubo: any, input: ReachabilityKuboInput) => {
const { kuboConfig, kuboEdits } = planKuboConfig(kubo, input, true);
return { kubo: kuboConfig, input: { ...input, kuboEdits } };
};
const disable = (kubo: any, input: ReachabilityKuboInput) => {
const { kuboConfig, kuboEdits } = planKuboConfig(kubo, input, false);
return { kubo: kuboConfig, input: { ...input, kuboEdits } };
};
const bytes = (value: unknown) => JSON.stringify(value, null, 4);

describe("computeKuboConfig when enabled", () => {
it("adds the local ws and WebTransport listeners next to the existing ones", () => {
const result = computeKuboConfig(serverProfileConfig(), reachability(), true);
expect(result.Addresses.Swarm).toEqual([...DEFAULT_SWARM, localWsSwarmAddress(4002), ...WEBTRANSPORT_SWARM_ADDRESSES]);
});

it("announces every address that succeeded", () => {
expect(computeKuboConfig(serverProfileConfig(), reachability(), true).Addresses.AppendAnnounce).toEqual([ANNOUNCE_V4, ANNOUNCE_V6]);
});

it("removes only the loopback AddrFilter", () => {
const result = computeKuboConfig(serverProfileConfig(), reachability(), true);
expect(result.Swarm.AddrFilters).toEqual(SERVER_ADDR_FILTERS.filter((entry) => entry !== LOOPBACK_ADDR_FILTER));
});

it("turns AutoTLS off and leaves every other key alone", () => {
const result = computeKuboConfig(serverProfileConfig(), reachability(), true);
expect(result.AutoTLS.Enabled).toBe(false);
expect(result.Identity).toEqual({ PeerID: "peer" });
expect(result.Swarm.DisableNatPortMap).toBe(true);
expect(result.Addresses.API).toBe("/ip4/127.0.0.1/tcp/50019");
});

it("does not mutate its input", () => {
const input = serverProfileConfig();
const before = bytes(input);
computeKuboConfig(input, reachability(), true);
expect(bytes(input)).toBe(before);
});

it("is idempotent", () => {
const first = enable(serverProfileConfig(), reachability());
const second = enable(first.kubo, first.input);
expect(bytes(second.kubo)).toBe(bytes(first.kubo));
expect(second.input.kuboEdits).toEqual(first.input.kuboEdits);
});

it("replaces the old address after an IP change", () => {
const first = enable(serverProfileConfig(), reachability());
const moved = "/ip4/203.0.113.99/tcp/443/tls/ws";
const second = enable(first.kubo, { ...first.input, announce: [moved] });
expect(second.kubo.Addresses.AppendAnnounce).toEqual([moved]);
});

it("replaces the old listener when the ws port moves", () => {
const first = enable(serverProfileConfig(), reachability());
const second = enable(first.kubo, { ...first.input, wsPort: 4003 });
expect(second.kubo.Addresses.Swarm).toContain(localWsSwarmAddress(4003));
expect(second.kubo.Addresses.Swarm).not.toContain(localWsSwarmAddress(4002));
});

it("removes a loopback filter the operator put back, since it would block the terminator", () => {
const first = enable(serverProfileConfig(), reachability());
first.kubo.Swarm.AddrFilters.push(LOOPBACK_ADDR_FILTER);
expect(enable(first.kubo, first.input).kubo.Swarm.AddrFilters).not.toContain(LOOPBACK_ADDR_FILTER);
});

it("keeps AutoTLS on after a repair if the operator flipped it back", () => {
const first = enable(serverProfileConfig(), reachability());
first.kubo.AutoTLS.Enabled = true;
expect(enable(first.kubo, first.input).kubo.AutoTLS.Enabled).toBe(true);
});
});

describe("computeKuboConfig when disabled", () => {
it("changes nothing if reachability was never applied", () => {
const input = serverProfileConfig();
expect(bytes(computeKuboConfig(input, reachability(), false))).toBe(bytes(input));
});

it("keeps an identical entry the operator had before we enabled", () => {
const original = serverProfileConfig();
original.Addresses.Swarm.push(WEBTRANSPORT_SWARM_ADDRESSES[0]);
(original.Addresses.AppendAnnounce as string[]).push(ANNOUNCE_V4);
const enabled = enable(original, reachability());
expect(bytes(disable(enabled.kubo, enabled.input).kubo)).toBe(bytes(original));
});

it("keeps what the operator added while enabled", () => {
const enabled = enable(serverProfileConfig(), reachability());
enabled.kubo.Addresses.Swarm.push("/ip4/0.0.0.0/tcp/9999");
enabled.kubo.Addresses.AppendAnnounce.push("/dns4/operator.example/tcp/443/tls/ws");
enabled.kubo.Swarm.AddrFilters.push("/ip4/192.0.2.0/ipcidr/24");
const { kubo } = disable(enabled.kubo, enabled.input);
expect(kubo.Addresses.Swarm).toEqual([...DEFAULT_SWARM, "/ip4/0.0.0.0/tcp/9999"]);
expect(kubo.Addresses.AppendAnnounce).toEqual(["/dns4/operator.example/tcp/443/tls/ws"]);
expect(kubo.Swarm.AddrFilters).toEqual([...SERVER_ADDR_FILTERS, "/ip4/192.0.2.0/ipcidr/24"]);
});

it("does not override an operator's AutoTLS flip", () => {
const original = serverProfileConfig();
original.AutoTLS.Enabled = false;
const enabled = enable(original, reachability());
enabled.kubo.AutoTLS.Enabled = true;
expect(disable(enabled.kubo, enabled.input).kubo.AutoTLS.Enabled).toBe(true);
});

it("clears the edit record", () => {
const enabled = enable(serverProfileConfig(), reachability());
expect(disable(enabled.kubo, enabled.input).input.kuboEdits).toBeUndefined();
});
});

describe("disable/enable byte-for-byte round trip", () => {
const fixtures: [string, () => any][] = [
["server profile", serverProfileConfig],
["empty config", () => ({})],
["no AutoTLS section", () => ({ Addresses: { Swarm: [...DEFAULT_SWARM] }, Swarm: { AddrFilters: null } })],
["AutoTLS section without Enabled", () => ({ AutoTLS: {}, Addresses: { Swarm: [] } })],
["AutoTLS explicitly off", () => ({ AutoTLS: { Enabled: false, ShortAddrs: true } })],
["null AutoTLS section", () => ({ AutoTLS: null, Addresses: { Swarm: [] } })],
["null Addresses section", () => ({ Addresses: null, AutoTLS: { Enabled: true } })],
["null AutoTLS.Enabled", () => ({ AutoTLS: { Enabled: null } })],
["null lists", () => ({ Addresses: { Swarm: null, AppendAnnounce: null }, Swarm: { AddrFilters: null } })],
["loopback filter first", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER, "/ip4/10.0.0.0/ipcidr/8"] } })],
["loopback filter last", () => ({ Swarm: { AddrFilters: ["/ip4/10.0.0.0/ipcidr/8", LOOPBACK_ADDR_FILTER] } })],
["loopback filter twice", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER, "/ip4/10.0.0.0/ipcidr/8", LOOPBACK_ADDR_FILTER] } })],
["only the loopback filter", () => ({ Swarm: { AddrFilters: [LOOPBACK_ADDR_FILTER] } })],
["no loopback filter", () => ({ Swarm: { AddrFilters: ["/ip4/10.0.0.0/ipcidr/8"] } })],
["operator already listens on udp/443", () => ({ Addresses: { Swarm: [...WEBTRANSPORT_SWARM_ADDRESSES, "/ip4/0.0.0.0/tcp/4001"] } })],
["operator already announces our address", () => ({ Addresses: { AppendAnnounce: [ANNOUNCE_V6, "/ip4/192.0.2.1/tcp/1"] } })],
["unusual key order", () => ({ AutoTLS: { Enabled: true }, Swarm: {}, Identity: {}, Addresses: { AppendAnnounce: [], API: "x", Swarm: [] } })]
];

it.each(fixtures)("%s: disable(enable(x)) is x", (_name, fixture) => {
const original = fixture();
const enabled = enable(original, reachability());
expect(bytes(disable(enabled.kubo, enabled.input).kubo)).toBe(bytes(original));
});

it.each(fixtures)("%s: enable(disable(enable(x))) is enable(x)", (_name, fixture) => {
const enabled = enable(fixture(), reachability());
const disabled = disable(enabled.kubo, enabled.input);
const reenabled = enable(disabled.kubo, disabled.input);
expect(bytes(reenabled.kubo)).toBe(bytes(enabled.kubo));
expect(reenabled.input.kuboEdits).toEqual(enabled.input.kuboEdits);
});

it.each(fixtures)("%s: survives an IP change and a port move before disable", (_name, fixture) => {
const original = fixture();
const first = enable(original, reachability());
const moved = enable(first.kubo, { ...first.input, announce: ["/ip4/203.0.113.99/tcp/443/tls/ws"], wsPort: 4003 });
expect(bytes(disable(moved.kubo, moved.input).kubo)).toBe(bytes(original));
});

it.each(fixtures)("%s: stays exact when the record goes through config.json", (_name, fixture) => {
const original = fixture();
const enabled = enable(original, reachability());
const reloaded = JSON.parse(JSON.stringify(enabled.input));
expect(bytes(disable(JSON.parse(bytes(enabled.kubo)), reloaded).kubo)).toBe(bytes(original));
});
});
Loading