diff --git a/Containerfile.fast b/Containerfile.fast new file mode 100644 index 0000000..88f8389 --- /dev/null +++ b/Containerfile.fast @@ -0,0 +1,22 @@ +FROM archlinux:latest + +RUN pacman -Syu --noconfirm \ + sqlite \ + gcc-libs \ + bash \ + procps + +WORKDIR /app + +# Copy built binary from host +COPY capsule-core/zig-out/bin/capsule /usr/bin/capsule + +# Copy duckdb from local context +COPY libs/libduckdb.so /usr/lib/libduckdb.so + +# Expose ports +EXPOSE 9000/udp +EXPOSE 5353/udp + +# Entrypoint +CMD ["capsule", "start"] diff --git a/Containerfile.wolfi b/Containerfile.wolfi new file mode 100644 index 0000000..f0965db --- /dev/null +++ b/Containerfile.wolfi @@ -0,0 +1,35 @@ +FROM cgr.dev/chainguard/wolfi-base:latest + +RUN apk update && apk add \ + zig \ + build-base \ + git \ + sqlite-dev \ + bash \ + curl \ + unzip + +# Install DuckDB +RUN curl -L -o libduckdb.zip https://github.com/duckdb/duckdb/releases/download/v1.1.3/libduckdb-linux-amd64.zip && \ + unzip libduckdb.zip -d /usr/local && \ + rm libduckdb.zip && \ + ln -s /usr/local/libduckdb.so /usr/lib/libduckdb.so && \ + cp /usr/local/duckdb.h /usr/include/duckdb.h + +WORKDIR /app + +# Copy SDK +COPY . . + +# Build Capsule Core +WORKDIR /app/capsule-core +RUN zig build + +# Expose ports +# 9000: UTCP/P2P +# 5353: mDNS +EXPOSE 9000/udp +EXPOSE 5353/udp + +# Entrypoint +CMD ["./zig-out/bin/capsule", "start"] diff --git a/build.zig b/build.zig index cc9806b..f166ede 100644 --- a/build.zig +++ b/build.zig @@ -51,6 +51,25 @@ pub fn build(b: *std.Build) void { l0_service_mod.addImport("utcp", utcp_mod); l0_service_mod.addImport("opq", opq_mod); + const dht_mod = b.createModule(.{ + .root_source_file = b.path("l0-transport/dht.zig"), + .target = target, + .optimize = optimize, + }); + + const gateway_mod = b.createModule(.{ + .root_source_file = b.path("l0-transport/gateway.zig"), + .target = target, + .optimize = optimize, + }); + gateway_mod.addImport("dht", dht_mod); + + const relay_mod = b.createModule(.{ + .root_source_file = b.path("l0-transport/relay.zig"), + .target = target, + .optimize = optimize, + }); + // ======================================================================== // Crypto: SHA3/SHAKE & FIPS 202 // ======================================================================== @@ -235,6 +254,24 @@ pub fn build(b: *std.Build) void { }); const run_l0_service_tests = b.addRunArtifact(l0_service_tests); + // DHT tests + const dht_tests = b.addTest(.{ + .root_module = dht_mod, + }); + const run_dht_tests = b.addRunArtifact(dht_tests); + + // Gateway tests + const gateway_tests = b.addTest(.{ + .root_module = gateway_mod, + }); + const run_gateway_tests = b.addRunArtifact(gateway_tests); + + // Relay tests + const relay_tests = b.addTest(.{ + .root_module = relay_mod, + }); + const run_relay_tests = b.addRunArtifact(relay_tests); + // L1 SoulKey tests (Phase 2B) const l1_soulkey_tests = b.addTest(.{ .root_module = l1_soulkey_mod, @@ -320,6 +357,7 @@ pub fn build(b: *std.Build) void { l1_vector_mod.addImport("time", time_mod); l1_vector_mod.addImport("pqxdh", l1_pqxdh_mod); l1_vector_mod.addImport("trust_graph", l1_trust_graph_mod); + l1_vector_mod.addImport("soulkey", l1_soulkey_mod); const l1_vector_tests = b.addTest(.{ .root_module = l1_vector_mod, @@ -377,6 +415,9 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_utcp_tests.step); test_step.dependOn(&run_opq_tests.step); test_step.dependOn(&run_l0_service_tests.step); + test_step.dependOn(&run_dht_tests.step); + test_step.dependOn(&run_gateway_tests.step); + test_step.dependOn(&run_relay_tests.step); test_step.dependOn(&run_l1_qvl_tests.step); test_step.dependOn(&run_l1_qvl_ffi_tests.step); @@ -442,6 +483,10 @@ pub fn build(b: *std.Build) void { // Link L1 (Identity) capsule_mod.addImport("l1_identity", l1_mod); capsule_mod.addImport("qvl", l1_qvl_mod); + capsule_mod.addImport("dht", dht_mod); + capsule_mod.addImport("gateway", gateway_mod); + capsule_mod.addImport("relay", relay_mod); + capsule_mod.addImport("quarantine", l0_quarantine_mod); const capsule_exe = b.addExecutable(.{ .name = "capsule", diff --git a/build_err.txt b/build_err.txt new file mode 100644 index 0000000..7968b0e --- /dev/null +++ b/build_err.txt @@ -0,0 +1,20 @@ +install ++- install capsule + +- compile exe capsule Debug native 1 errors +capsule-core/src/tui/app.zig:5:23: error: no module named 'vaxis' available within module 'root' +const vaxis = @import("vaxis"); + ^~~~~~~ +referenced by: + run: capsule-core/src/tui/app.zig:64:18 + main: capsule-core/src/main.zig:132:24 + 4 reference(s) hidden; use '-freference-trace=6' to see all references +error: the following command failed with 1 compilation errors: +/usr/bin/zig build-exe -lsqlite3 -lduckdb -ODebug --dep l0_transport=lwf --dep utcp --dep l1_identity=crypto --dep qvl --dep dht --dep gateway --dep quarantine -Mroot=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/capsule-core/src/main.zig -ODebug -Mlwf=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/lwf.zig -ODebug --dep ipc --dep lwf --dep entropy --dep quarantine -Mutcp=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/utcp/socket.zig -ODebug --dep shake --dep fips202_bridge --dep pqxdh --dep slash -Mcrypto=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/crypto.zig -ODebug --dep trust_graph --dep time -Mqvl=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/qvl.zig -ODebug -Mdht=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/dht.zig -ODebug --dep dht -Mgateway=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/gateway.zig -ODebug -Mquarantine=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/quarantine.zig -ODebug -Mipc=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/ipc/client.zig -cflags -std=c99 -O3 -fPIC -DHAVE_PTHREAD -- /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/argon2.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/core.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/blake2/blake2b.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/thread.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/encoding.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/opt.c -ODebug -I /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/include -Mentropy=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/entropy.zig -ODebug -Mshake=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/shake.zig -ODebug -Mfips202_bridge=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/fips202_bridge.zig -needed-loqs -ODebug -I /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/liboqs/install/include -L /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/liboqs/install/lib -Mpqxdh=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/pqxdh.zig -ODebug -Mslash=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/slash.zig -ODebug --dep crypto -Mtrust_graph=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/trust_graph.zig -ODebug -Mtime=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/time.zig -lc --cache-dir .zig-cache --global-cache-dir /home/markus/.cache/zig --name capsule --zig-lib-dir /usr/lib/zig/ --listen=- + +Build Summary: 6/9 steps succeeded; 1 failed +install transitive failure ++- install capsule transitive failure + +- compile exe capsule Debug native 1 errors + +error: the following build command failed with exit code 1: +.zig-cache/o/b5937c8bf2970c610fb30d2e05efe33c/build /usr/bin/zig /usr/lib/zig /home/markus/zWork/_Git/Libertaria/libertaria-sdk .zig-cache /home/markus/.cache/zig --seed 0x8d98622f -Z2d0f55519cb30a7a diff --git a/build_error_j1.txt b/build_error_j1.txt new file mode 100644 index 0000000..e716d22 --- /dev/null +++ b/build_error_j1.txt @@ -0,0 +1,20 @@ +install ++- install capsule + +- compile exe capsule Debug native 1 errors +capsule-core/src/node.zig:22:32: error: no module named 'quarantine' available within module 'root' +const quarantine_mod = @import("quarantine"); + ^~~~~~~~~~~~ +referenced by: + node.CapsuleNode: capsule-core/src/node.zig:79:19 + CapsuleNode: capsule-core/src/node.zig:62:25 + 6 reference(s) hidden; use '-freference-trace=8' to see all references +error: the following command failed with 1 compilation errors: +/usr/bin/zig build-exe -lsqlite3 -lduckdb -ODebug --dep l0_transport=lwf --dep utcp --dep l1_identity=crypto --dep qvl --dep dht -Mroot=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/capsule-core/src/main.zig -ODebug -Mlwf=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/lwf.zig -ODebug --dep ipc --dep lwf --dep entropy --dep quarantine -Mutcp=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/utcp/socket.zig -ODebug --dep shake --dep fips202_bridge --dep pqxdh --dep slash -Mcrypto=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/crypto.zig -ODebug --dep trust_graph --dep time -Mqvl=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/qvl.zig -ODebug -Mdht=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/dht.zig -ODebug -Mipc=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/ipc/client.zig -cflags -std=c99 -O3 -fPIC -DHAVE_PTHREAD -- /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/argon2.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/core.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/blake2/blake2b.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/thread.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/encoding.c /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/src/opt.c -ODebug -I /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/argon2/include -Mentropy=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/entropy.zig -ODebug -Mquarantine=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/quarantine.zig -ODebug -Mshake=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/shake.zig -ODebug -Mfips202_bridge=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/fips202_bridge.zig -needed-loqs -ODebug -I /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/liboqs/install/include -L /home/markus/zWork/_Git/Libertaria/libertaria-sdk/vendor/liboqs/install/lib -Mpqxdh=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/pqxdh.zig -ODebug -Mslash=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/slash.zig -ODebug --dep crypto -Mtrust_graph=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/trust_graph.zig -ODebug -Mtime=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/time.zig -lc --cache-dir .zig-cache --global-cache-dir /home/markus/.cache/zig --name capsule --zig-lib-dir /usr/lib/zig/ --listen=- + +Build Summary: 6/9 steps succeeded; 1 failed +install transitive failure ++- install capsule transitive failure + +- compile exe capsule Debug native 1 errors + +error: the following build command failed with exit code 1: +.zig-cache/o/adaac25b0555a4724eacbe0f6ad253fd/build /usr/bin/zig /usr/lib/zig /home/markus/zWork/_Git/Libertaria/libertaria-sdk .zig-cache /home/markus/.cache/zig --seed 0xbbd073e3 -Z6bc5376addff02a3 -j1 diff --git a/capsule-core/build.zig b/capsule-core/build.zig index 75f8706..5466c7f 100644 --- a/capsule-core/build.zig +++ b/capsule-core/build.zig @@ -73,6 +73,12 @@ pub fn build(b: *std.Build) void { }, }); + const vaxis_dep = b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }); + const vaxis_mod = vaxis_dep.module("vaxis"); + const exe_mod = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, @@ -89,6 +95,7 @@ pub fn build(b: *std.Build) void { exe.root_module.addImport("l1_identity", crypto); // Name mismatch? Step 4902 says l1_identity=crypto exe.root_module.addImport("qvl", qvl); exe.root_module.addImport("quarantine", quarantine); + exe.root_module.addImport("vaxis", vaxis_mod); exe.linkSystemLibrary("sqlite3"); exe.linkSystemLibrary("duckdb"); diff --git a/capsule-core/build.zig.zon b/capsule-core/build.zig.zon new file mode 100644 index 0000000..2065512 --- /dev/null +++ b/capsule-core/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .capsule_core, + .version = "0.15.2", + .dependencies = .{ + .vaxis = .{ + .url = "https://github.com/rockorager/libvaxis/archive/refs/heads/main.tar.gz", + .hash = "vaxis-0.5.1-BWNV_Bw_CQAIVNh1ekGVzbip25CYBQ_J3kgABnYGFnI4", + }, + }, + .paths = .{""}, + .fingerprint = 0x8a316e2234f72ed1, +} diff --git a/capsule-core/build_errors.txt b/capsule-core/build_errors.txt new file mode 100644 index 0000000..3ab9083 --- /dev/null +++ b/capsule-core/build_errors.txt @@ -0,0 +1,20 @@ +install ++- install capsule + +- compile exe capsule Debug native 1 errors +/usr/lib/zig/std/Io/Writer.zig:1200:9: error: ambiguous format string; specify {f} to call format method, or {any} to skip it + @compileError("ambiguous format string; specify {f} to call format method, or {any} to skip it"); + ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +referenced by: + print__anon_34066: /usr/lib/zig/std/Io/Writer.zig:700:25 + bufPrint__anon_28107: /usr/lib/zig/std/fmt.zig:614:12 + 11 reference(s) hidden; use '-freference-trace=13' to see all references +error: the following command failed with 1 compilation errors: +/usr/bin/zig build-exe -lsqlite3 -lduckdb -ODebug --dep l0_transport=lwf --dep utcp --dep l1_identity --dep qvl --dep quarantine --dep vaxis -Mroot=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/capsule-core/src/main.zig --dep ipc --dep entropy --dep quarantine -Mlwf=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/lwf.zig --dep shake --dep fips202_bridge --dep pqxdh --dep slash --dep ipc --dep lwf --dep entropy -Mutcp=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/utcp/socket.zig --dep trust_graph --dep time -Ml1_identity=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/crypto.zig --dep time -Mqvl=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/qvl.zig -Mquarantine=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/quarantine.zig -ODebug --dep zigimg --dep uucode -Mvaxis=/home/markus/.cache/zig/p/vaxis-0.5.1-BWNV_Bw_CQAIVNh1ekGVzbip25CYBQ_J3kgABnYGFnI4/src/main.zig -Mipc=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/ipc/client.zig -Mentropy=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/entropy.zig -Mshake=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/shake.zig -Mfips202_bridge=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/src/crypto/fips202_bridge.zig -Mpqxdh=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/pqxdh.zig --dep crypto -Mslash=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/slash.zig -Mtrust_graph=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/trust_graph.zig -Mtime=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l0-transport/time.zig -ODebug --dep zigimg -Mzigimg=/home/markus/.cache/zig/p/zigimg-0.1.0-8_eo2vUZFgAAtN1c6dAO5DdqL0d4cEWHtn6iR5ucZJti/zigimg.zig -ODebug --dep types.zig --dep config.zig --dep types.x.zig --dep tables --dep get.zig -Muucode=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/root.zig -Mcrypto=/home/markus/zWork/_Git/Libertaria/libertaria-sdk/l1-identity/crypto.zig -ODebug --dep config.zig --dep get.zig -Mtypes.zig=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/types.zig -ODebug --dep types.zig -Mconfig.zig=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/config.zig -ODebug --dep config.x.zig -Mtypes.x.zig=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/x/types.x.zig -ODebug --dep types.zig --dep types.x.zig --dep config.zig --dep build_config -Mtables=.zig-cache/o/f992ecbd8ddf0ce62acb8ad5f358027c/tables.zig -ODebug --dep types.zig --dep tables -Mget.zig=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/get.zig -ODebug --dep types.x.zig --dep types.zig --dep config.zig -Mconfig.x.zig=/home/markus/.cache/zig/p/uucode-0.1.0-ZZjBPj96QADXyt5sqwBJUnhaDYs_qBeeKijZvlRa0eqM/src/x/config.x.zig --dep types.zig --dep config.zig --dep types.x.zig --dep config.x.zig -Mbuild_config=.zig-cache/o/fd18b32249ff398bc4015853405e77cf/build_config2.zig -lc --cache-dir .zig-cache --global-cache-dir /home/markus/.cache/zig --name capsule --zig-lib-dir /usr/lib/zig/ --listen=- + +Build Summary: 3/6 steps succeeded; 1 failed +install transitive failure ++- install capsule transitive failure + +- compile exe capsule Debug native 1 errors + +error: the following build command failed with exit code 1: +.zig-cache/o/4b65275f5eb170ee27bba10d107c990c/build /usr/bin/zig /usr/lib/zig /home/markus/zWork/_Git/Libertaria/libertaria-sdk/capsule-core .zig-cache /home/markus/.cache/zig --seed 0xb9d460a9 -Z4967dc0931849eb3 diff --git a/capsule-core/src/config.zig b/capsule-core/src/config.zig index 22e497b..d3e2233 100644 --- a/capsule-core/src/config.zig +++ b/capsule-core/src/config.zig @@ -21,6 +21,9 @@ pub const NodeConfig = struct { /// Logging level log_level: std.log.Level = .info, + /// Enable Gateway Service (Layer 1 Coordination) + gateway_enabled: bool = false, + /// Free allocated memory (strings, slices) pub fn deinit(self: *NodeConfig, allocator: std.mem.Allocator) void { allocator.free(self.data_dir); @@ -39,6 +42,7 @@ pub const NodeConfig = struct { .control_socket_path = try allocator.dupe(u8, "data/capsule.sock"), .identity_key_path = try allocator.dupe(u8, "data/identity.key"), .port = 8710, + .gateway_enabled = false, }; } @@ -96,6 +100,7 @@ pub const NodeConfig = struct { .port = cfg.port, .bootstrap_peers = try peers.toOwnedSlice(), .log_level = cfg.log_level, + .gateway_enabled = cfg.gateway_enabled, }; } diff --git a/capsule-core/src/control.zig b/capsule-core/src/control.zig index 91e32d0..cc282d0 100644 --- a/capsule-core/src/control.zig +++ b/capsule-core/src/control.zig @@ -36,6 +36,8 @@ pub const Command = union(enum) { Airlock: AirlockArgs, /// Shutdown the daemon (admin only) Shutdown: void, + /// Get Topology for Graph Visualization + Topology: void, }; pub const SlashArgs = struct { @@ -87,6 +89,8 @@ pub const Response = union(enum) { IdentityInfo: IdentityInfo, /// Lockdown status LockdownStatus: LockdownInfo, + /// Topology info + TopologyInfo: TopologyInfo, /// QVL query results QvlResult: QvlMetrics, /// Slash Log results @@ -142,6 +146,24 @@ pub const LockdownInfo = struct { locked_since: i64, }; +pub const TopologyInfo = struct { + nodes: []const GraphNode, + edges: []const GraphEdge, +}; + +pub const GraphNode = struct { + id: []const u8, // short did or node id + trust_score: f64, + status: []const u8, // "active", "slashed", "ok" + role: []const u8, // "self", "peer" +}; + +pub const GraphEdge = struct { + source: []const u8, + target: []const u8, + weight: f64, +}; + pub const SlashEvent = struct { timestamp: u64, target_did: []const u8, diff --git a/capsule-core/src/federation.zig b/capsule-core/src/federation.zig index 2a1d939..265d84b 100644 --- a/capsule-core/src/federation.zig +++ b/capsule-core/src/federation.zig @@ -44,6 +44,14 @@ pub const FederationMessage = union(enum) { dht_nodes: struct { nodes: []const DhtNode, }, + // Gateway Coordination + hole_punch_request: struct { + target_id: [32]u8, + }, + hole_punch_notify: struct { + peer_id: [32]u8, + address: net.Address, + }, pub fn encode(self: FederationMessage, writer: anytype) !void { try writer.writeByte(@intFromEnum(self)); @@ -80,6 +88,19 @@ pub const FederationMessage = union(enum) { } } }, + .hole_punch_request => |r| { + try writer.writeAll(&r.target_id); + }, + .hole_punch_notify => |n| { + try writer.writeAll(&n.peer_id); + // Serialize address (IPv4 only for now) + if (n.address.any.family == std.posix.AF.INET) { + try writer.writeAll(&std.mem.toBytes(n.address.in.sa.addr)); + try writer.writeInt(u16, n.address.getPort(), .big); + } else { + return error.UnsupportedAddressFamily; + } + }, } } @@ -131,6 +152,22 @@ pub const FederationMessage = union(enum) { } return .{ .dht_nodes = .{ .nodes = nodes } }; }, + .hole_punch_request => .{ + .hole_punch_request = .{ + .target_id = try reader.readBytesNoEof(32), + }, + }, + .hole_punch_notify => { + const id = try reader.readBytesNoEof(32); + const addr_u32 = try reader.readInt(u32, @import("builtin").target.cpu.arch.endian()); + const port = try reader.readInt(u16, .big); + return .{ + .hole_punch_notify = .{ + .peer_id = id, + .address = net.Address.initIp4(std.mem.toBytes(addr_u32), port), + }, + }; + }, }; } }; diff --git a/capsule-core/src/main.zig b/capsule-core/src/main.zig index 3f8eb61..ead1a4f 100644 --- a/capsule-core/src/main.zig +++ b/capsule-core/src/main.zig @@ -5,6 +5,7 @@ const node_mod = @import("node.zig"); const config_mod = @import("config.zig"); const control_mod = @import("control.zig"); +const tui_app = @import("tui/app.zig"); pub fn main() !void { // Setup allocator @@ -16,98 +17,119 @@ pub fn main() !void { const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); - if (args.len < 2) { + var data_dir_override: ?[]const u8 = null; + var port_override: ?u16 = null; + + // Parse global options and find command index + var cmd_idx: usize = 1; + var i: usize = 1; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--data-dir") and i + 1 < args.len) { + data_dir_override = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, args[i], "--port") and i + 1 < args.len) { + port_override = std.fmt.parseInt(u16, args[i + 1], 10) catch null; + i += 1; + } else { + cmd_idx = i; + break; + } + } + + if (cmd_idx >= args.len) { printUsage(); return; } - const command = args[1]; + const command = args[cmd_idx]; if (std.mem.eql(u8, command, "start")) { - try runDaemon(allocator); + // start already parses its own but we can unify + try runDaemon(allocator, port_override, data_dir_override); } else if (std.mem.eql(u8, command, "status")) { - try runCliCommand(allocator, .Status); + try runCliCommand(allocator, .Status, data_dir_override); } else if (std.mem.eql(u8, command, "peers")) { - try runCliCommand(allocator, .Peers); + try runCliCommand(allocator, .Peers, data_dir_override); } else if (std.mem.eql(u8, command, "stop")) { - try runCliCommand(allocator, .Shutdown); + try runCliCommand(allocator, .Shutdown, data_dir_override); } else if (std.mem.eql(u8, command, "version")) { std.debug.print("Libertaria Capsule v0.1.0 (Shield)\n", .{}); } else if (std.mem.eql(u8, command, "slash")) { - if (args.len < 5) { + if (args.len < cmd_idx + 4) { std.debug.print("Usage: capsule slash \n", .{}); return; } - const target_did = args[2]; - const reason = args[3]; - const severity = args[4]; + const target_did = args[cmd_idx + 1]; + const reason = args[cmd_idx + 2]; + const severity = args[cmd_idx + 3]; - // Validation could happen here or in node try runCliCommand(allocator, .{ .Slash = .{ .target_did = try allocator.dupe(u8, target_did), .reason = try allocator.dupe(u8, reason), .severity = try allocator.dupe(u8, severity), .duration = 0, - } }); + } }, data_dir_override); } else if (std.mem.eql(u8, command, "slash-log")) { var limit: usize = 50; - if (args.len >= 3) { - limit = std.fmt.parseInt(usize, args[2], 10) catch 50; + if (args.len >= cmd_idx + 2) { + limit = std.fmt.parseInt(usize, args[cmd_idx + 1], 10) catch 50; } - try runCliCommand(allocator, .{ .SlashLog = .{ .limit = limit } }); + try runCliCommand(allocator, .{ .SlashLog = .{ .limit = limit } }, data_dir_override); } else if (std.mem.eql(u8, command, "ban")) { - if (args.len < 4) { + if (args.len < cmd_idx + 3) { std.debug.print("Usage: capsule ban \n", .{}); return; } - const target_did = args[2]; - const reason = args[3]; + const target_did = args[cmd_idx + 1]; + const reason = args[cmd_idx + 2]; try runCliCommand(allocator, .{ .Ban = .{ .target_did = try allocator.dupe(u8, target_did), .reason = try allocator.dupe(u8, reason), - } }); + } }, data_dir_override); } else if (std.mem.eql(u8, command, "unban")) { - if (args.len < 3) { + if (args.len < cmd_idx + 2) { std.debug.print("Usage: capsule unban \n", .{}); return; } - const target_did = args[2]; + const target_did = args[cmd_idx + 1]; try runCliCommand(allocator, .{ .Unban = .{ .target_did = try allocator.dupe(u8, target_did), - } }); + } }, data_dir_override); } else if (std.mem.eql(u8, command, "trust")) { - if (args.len < 4) { + if (args.len < cmd_idx + 3) { std.debug.print("Usage: capsule trust \n", .{}); return; } - const target_did = args[2]; - const score = std.fmt.parseFloat(f64, args[3]) catch { - std.debug.print("Error: Invalid score '{s}', must be a number\n", .{args[3]}); + const target_did = args[cmd_idx + 1]; + const score = std.fmt.parseFloat(f64, args[cmd_idx + 2]) catch { + std.debug.print("Error: Invalid score '{s}', must be a number\n", .{args[cmd_idx + 2]}); return; }; try runCliCommand(allocator, .{ .Trust = .{ .target_did = try allocator.dupe(u8, target_did), .score = score, - } }); + } }, data_dir_override); } else if (std.mem.eql(u8, command, "sessions")) { - try runCliCommand(allocator, .Sessions); + try runCliCommand(allocator, .Sessions, data_dir_override); } else if (std.mem.eql(u8, command, "dht")) { - try runCliCommand(allocator, .Dht); + try runCliCommand(allocator, .Dht, data_dir_override); } else if (std.mem.eql(u8, command, "qvl-query")) { var target_did: ?[]const u8 = null; - if (args.len >= 3) { - target_did = try allocator.dupe(u8, args[2]); + if (args.len >= cmd_idx + 2) { + target_did = try allocator.dupe(u8, args[cmd_idx + 1]); } - try runCliCommand(allocator, .{ .QvlQuery = .{ .target_did = target_did } }); + try runCliCommand(allocator, .{ .QvlQuery = .{ .target_did = target_did } }, data_dir_override); } else if (std.mem.eql(u8, command, "identity")) { - try runCliCommand(allocator, .Identity); + try runCliCommand(allocator, .Identity, data_dir_override); } else if (std.mem.eql(u8, command, "lockdown")) { - try runCliCommand(allocator, .Lockdown); + try runCliCommand(allocator, .Lockdown, data_dir_override); } else if (std.mem.eql(u8, command, "unlock")) { - try runCliCommand(allocator, .Unlock); + try runCliCommand(allocator, .Unlock, data_dir_override); } else if (std.mem.eql(u8, command, "airlock")) { - const state = if (args.len > 2) args[2] else "open"; - try runCliCommand(allocator, .{ .Airlock = .{ .state = state } }); + const state = if (args.len > cmd_idx + 1) args[cmd_idx + 1] else "open"; + try runCliCommand(allocator, .{ .Airlock = .{ .state = state } }, data_dir_override); + } else if (std.mem.eql(u8, command, "monitor")) { + try tui_app.run(allocator, "dummy_socket_path"); } else { printUsage(); } @@ -134,21 +156,42 @@ fn printUsage() void { \\ identity Show node identity \\ lockdown Emergency network lockdown \\ unlock Resume normal operation - \\ airlock Set airlock mode + \\ airlock Set airlock mode + \\ monitor Launch TUI Dashboard \\ , .{}); } -fn runDaemon(allocator: std.mem.Allocator) !void { +fn runDaemon(allocator: std.mem.Allocator, port_override: ?u16, data_dir_override: ?[]const u8) !void { // Load Config // Check for config.json, otherwise use default const config_path = "config.json"; var config = config_mod.NodeConfig.loadFromJsonFile(allocator, config_path) catch |err| { + if (err == error.FileNotFound) { + std.log.info("Config missing, using defaults", .{}); + var cfg = try config_mod.NodeConfig.default(allocator); + if (port_override) |p| cfg.port = p; + if (data_dir_override) |d| { + allocator.free(cfg.data_dir); + cfg.data_dir = try allocator.dupe(u8, d); + } + const node = try node_mod.CapsuleNode.init(allocator, cfg); + defer node.deinit(); + try node.start(); + return; + } std.log.err("Failed to load configuration: {}", .{err}); return err; }; defer config.deinit(allocator); + // Apply Overrides + if (port_override) |p| config.port = p; + if (data_dir_override) |d| { + allocator.free(config.data_dir); + config.data_dir = try allocator.dupe(u8, d); + } + // Initialize Node const node = try node_mod.CapsuleNode.init(allocator, config); defer node.deinit(); @@ -157,7 +200,7 @@ fn runDaemon(allocator: std.mem.Allocator) !void { try node.start(); } -fn runCliCommand(allocator: std.mem.Allocator, cmd: control_mod.Command) !void { +fn runCliCommand(allocator: std.mem.Allocator, cmd: control_mod.Command, data_dir_override: ?[]const u8) !void { // Load config to find socket path const config_path = "config.json"; var config = config_mod.NodeConfig.loadFromJsonFile(allocator, config_path) catch { @@ -166,7 +209,12 @@ fn runCliCommand(allocator: std.mem.Allocator, cmd: control_mod.Command) !void { }; defer config.deinit(allocator); - const socket_path = config.control_socket_path; + const data_dir = data_dir_override orelse config.data_dir; + const socket_path = if (std.fs.path.isAbsolute(config.control_socket_path)) + try allocator.dupe(u8, config.control_socket_path) + else + try std.fs.path.join(allocator, &[_][]const u8{ data_dir, std.fs.path.basename(config.control_socket_path) }); + defer allocator.free(socket_path); var stream = std.net.connectUnixSocket(socket_path) catch |err| { std.log.err("Failed to connect to daemon at {s}: {}. Is it running?", .{ socket_path, err }); diff --git a/capsule-core/src/node.zig b/capsule-core/src/node.zig index 4b646b5..d666c91 100644 --- a/capsule-core/src/node.zig +++ b/capsule-core/src/node.zig @@ -15,7 +15,8 @@ const qvl = @import("qvl"); const discovery_mod = @import("discovery.zig"); const peer_table_mod = @import("peer_table.zig"); const fed = @import("federation.zig"); -const dht_mod = @import("dht.zig"); +const dht_mod = @import("dht"); +const gateway_mod = @import("gateway"); const storage_mod = @import("storage.zig"); const qvl_store_mod = @import("qvl_store.zig"); const control_mod = @import("control.zig"); @@ -70,6 +71,7 @@ pub const CapsuleNode = struct { peer_table: PeerTable, sessions: std.HashMap(std.net.Address, PeerSession, AddressContext, std.hash_map.default_max_load_percentage), dht: DhtService, + gateway: ?gateway_mod.Gateway, storage: *StorageService, qvl_store: *QvlStore, control_socket: std.net.Server, @@ -104,39 +106,41 @@ pub const CapsuleNode = struct { std.mem.copyForwards(u8, node_id[0..4], "NODE"); // Initialize Storage - var db_path_buf: [256]u8 = undefined; - const db_path = try std.fmt.bufPrint(&db_path_buf, "{s}/capsule.db", .{config.data_dir}); + const db_path = try std.fs.path.join(allocator, &[_][]const u8{ config.data_dir, "capsule.db" }); + defer allocator.free(db_path); const storage = try StorageService.init(allocator, db_path); - const qvl_db_path = try std.fmt.allocPrint(allocator, "{s}/qvl.db", .{config.data_dir}); + const qvl_db_path = try std.fs.path.join(allocator, &[_][]const u8{ config.data_dir, "qvl.db" }); defer allocator.free(qvl_db_path); const qvl_store = try QvlStore.init(allocator, qvl_db_path); - // Initialize Control Socket - const socket_path = config.control_socket_path; - // Unlink if exists (check logic in start, or here? start binds.) - // Load or Generate Identity var seed: [32]u8 = undefined; var identity: SoulKey = undefined; + const identity_path = if (std.fs.path.isAbsolute(config.identity_key_path)) + try allocator.dupe(u8, config.identity_key_path) + else + try std.fs.path.join(allocator, &[_][]const u8{ config.data_dir, std.fs.path.basename(config.identity_key_path) }); + defer allocator.free(identity_path); + // Try to open existing key file - if (std.fs.cwd().openFile(config.identity_key_path, .{})) |file| { + if (std.fs.cwd().openFile(identity_path, .{})) |file| { defer file.close(); const bytes_read = try file.readAll(&seed); if (bytes_read != 32) { - std.log.err("Identity: Invalid key file size at {s}", .{config.identity_key_path}); + std.log.err("Identity: Invalid key file size at {s}", .{identity_path}); return error.InvalidKeyFile; } - std.log.info("Identity: Loaded key from {s}", .{config.identity_key_path}); + std.log.info("Identity: Loaded key from {s}", .{identity_path}); identity = try SoulKey.fromSeed(&seed); } else |err| { if (err == error.FileNotFound) { - std.log.info("Identity: No key found at {s}, generating new...", .{config.identity_key_path}); + std.log.info("Identity: No key found at {s}, generating new...", .{identity_path}); std.crypto.random.bytes(&seed); // Save to file - const kf = try std.fs.cwd().createFile(config.identity_key_path, .{ .read = true }); + const kf = try std.fs.cwd().createFile(identity_path, .{ .read = true }); defer kf.close(); try kf.writeAll(&seed); @@ -151,6 +155,12 @@ pub const CapsuleNode = struct { @memcpy(&self.dht.routing_table.self_id, &identity.did); // Bind Control Socket + const socket_path = if (std.fs.path.isAbsolute(config.control_socket_path)) + try allocator.dupe(u8, config.control_socket_path) + else + try std.fs.path.join(allocator, &[_][]const u8{ config.data_dir, std.fs.path.basename(config.control_socket_path) }); + defer allocator.free(socket_path); + std.fs.cwd().deleteFile(socket_path) catch {}; const uds_address = try std.net.Address.initUnix(socket_path); @@ -165,7 +175,8 @@ pub const CapsuleNode = struct { .discovery = discovery, .peer_table = PeerTable.init(allocator), .sessions = std.HashMap(std.net.Address, PeerSession, AddressContext, 80).init(allocator), - .dht = DhtService.init(allocator, node_id), + .dht = undefined, // Initialized below + .gateway = null, // Initialized below .storage = storage, .qvl_store = qvl_store, .control_socket = control_socket, @@ -173,6 +184,14 @@ pub const CapsuleNode = struct { .running = false, .global_state = quarantine_mod.GlobalState{}, }; + // Initialize DHT in place + self.dht = DhtService.init(allocator, node_id); + + // Initialize Gateway (now safe to reference self.dht) + if (config.gateway_enabled) { + self.gateway = gateway_mod.Gateway.init(allocator, &self.dht); + std.log.info("Gateway Service: ENABLED", .{}); + } self.dht_timer = std.time.milliTimestamp(); self.qvl_timer = std.time.milliTimestamp(); @@ -192,6 +211,7 @@ pub const CapsuleNode = struct { self.discovery.deinit(); self.peer_table.deinit(); self.sessions.deinit(); + if (self.gateway) |*gw| gw.deinit(); self.dht.deinit(); self.storage.deinit(); self.qvl_store.deinit(); @@ -260,7 +280,14 @@ pub const CapsuleNode = struct { break :blk @as(usize, 0); }; if (bytes > 0) { - try self.discovery.handlePacket(&self.peer_table, m_buf[0..bytes], std.net.Address{ .any = src_addr }); + const addr = std.net.Address{ .any = src_addr }; + // Filter self-discovery + if (addr.getPort() == self.config.port) { + // Check local IPs if necessary, but port check is usually enough on same LAN for different nodes + // For local multi-port test, we allow it if port is different. + // But mDNS on host network might show our own announcement. + } + try self.discovery.handlePacket(&self.peer_table, m_buf[0..bytes], addr); } } @@ -438,6 +465,18 @@ pub const CapsuleNode = struct { } self.allocator.free(n.nodes); }, + .hole_punch_request => |req| { + if (self.gateway) |*gw| { + _ = gw; + std.log.info("Gateway: Received Hole Punch Request from {f} for {any}", .{ sender, req.target_id }); + } else { + std.log.debug("Node: Ignoring Hole Punch Request (Not a Gateway)", .{}); + } + }, + .hole_punch_notify => |notif| { + std.log.info("Node: Received Hole Punch Notification for peer {any} at {f}", .{ notif.peer_id, notif.address }); + try self.connectToPeer(notif.address, [_]u8{0} ** 8); + }, } } @@ -542,6 +581,10 @@ pub const CapsuleNode = struct { std.log.info("AIRLOCK: State set to {s}", .{args.state}); response = .{ .LockdownStatus = try self.getLockdownStatus() }; }, + .Topology => { + const topo = try self.getTopology(); + response = .{ .TopologyInfo = topo }; + }, } // Send Response - buffer to ArrayList then write to stream @@ -555,36 +598,33 @@ pub const CapsuleNode = struct { try conn.stream.writeAll(resp_buf.items); } - fn processSlashCommand(_: *CapsuleNode, args: control_mod.SlashArgs) !bool { + fn processSlashCommand(self: *CapsuleNode, args: control_mod.SlashArgs) !bool { std.log.warn("Slash: Initiated against {s} for {s}", .{ args.target_did, args.reason }); - const timestamp = std.time.timestamp(); + const timestamp: u64 = @intCast(std.time.timestamp()); + const evidence_hash = "EVIDENCE_HASH_STUB"; // TODO: Real evidence - // TODO: Import slash types properly when module structure is fixed - const SlashReason = enum { BetrayalCycle, Other }; - const SlashSeverity = enum { Quarantine, Ban }; + // Log to persistent QVL Store (DuckDB) + try self.qvl_store.logSlashEvent(timestamp, args.target_did, args.reason, args.severity, evidence_hash); - const reason_enum = std.meta.stringToEnum(SlashReason, args.reason) orelse .BetrayalCycle; - const severity_enum = std.meta.stringToEnum(SlashSeverity, args.severity) orelse .Quarantine; - - const evidence_hash: [32]u8 = [_]u8{0} ** 32; - - _ = timestamp; // TODO: Use timestamp when logging is enabled - _ = args.target_did; // TODO: Use when logging is enabled - - // TODO: Re-enable when QvlStore.logSlashEvent is implemented - _ = reason_enum; - _ = severity_enum; - _ = evidence_hash; - //try self.qvl_store.logSlashEvent(@intCast(timestamp), args.target_did, reason_enum, severity_enum, evidence_hash); return true; } fn getSlashLog(self: *CapsuleNode, limit: usize) ![]control_mod.SlashEvent { - _ = self; - _ = limit; - //TODO: Implement getSlashEvents when QvlStore API is stable - return &[_]control_mod.SlashEvent{}; + const stored = try self.qvl_store.getSlashEvents(limit); + defer self.allocator.free(stored); // Free the slice, keep content + + var result = try self.allocator.alloc(control_mod.SlashEvent, stored.len); + for (stored, 0..) |ev, i| { + result[i] = .{ + .timestamp = ev.timestamp, + .target_did = ev.target_did, + .reason = ev.reason, + .severity = ev.severity, + .evidence_hash = ev.evidence_hash, + }; + } + return result; } fn processBan(self: *CapsuleNode, args: control_mod.BanArgs) !bool { @@ -649,7 +689,7 @@ pub const CapsuleNode = struct { return control_mod.DhtInfo{ .local_node_id = try self.allocator.dupe(u8, &node_id_hex), .routing_table_size = self.dht.routing_table.buckets.len, - .known_nodes = 0, // TODO: Compute actual node count when RoutingTable API is stable + .known_nodes = self.dht.getKnownNodeCount(), }; } @@ -678,15 +718,57 @@ pub const CapsuleNode = struct { }; } + fn getTopology(self: *CapsuleNode) !control_mod.TopologyInfo { + // Collect nodes: Self + Peers + const peer_count = self.peer_table.peers.count(); + var nodes = try self.allocator.alloc(control_mod.GraphNode, peer_count + 1); + var edges = std.ArrayList(control_mod.GraphEdge){}; + + // 1. Add Self + const my_did = std.fmt.bytesToHex(&self.identity.did, .lower); + nodes[0] = .{ + .id = try self.allocator.dupe(u8, my_did[0..8]), // Short DID for display + .trust_score = 1.0, + .status = "active", + .role = "self", + }; + + // 2. Add Peers + var i: usize = 1; + var it = self.peer_table.peers.iterator(); + while (it.next()) |entry| : (i += 1) { + const peer_did = std.fmt.bytesToHex(&entry.key_ptr.*, .lower); + const peer_info = entry.value_ptr; + + nodes[i] = .{ + .id = try self.allocator.dupe(u8, peer_did[0..8]), + .trust_score = peer_info.trust_score, + .status = if (peer_info.trust_score < 0.2) "slashed" else "active", // Mock logic + .role = "peer", + }; + + // Edge from Self to Peer + try edges.append(self.allocator, .{ + .source = nodes[0].id, + .target = nodes[i].id, + .weight = peer_info.trust_score, + }); + } + + return control_mod.TopologyInfo{ + .nodes = nodes, + .edges = try edges.toOwnedSlice(self.allocator), + }; + } + fn getQvlMetrics(self: *CapsuleNode, args: control_mod.QvlQueryArgs) !control_mod.QvlMetrics { _ = args; // TODO: Use target_did for specific queries - _ = self; // TODO: Get actual metrics from the risk graph when API is stable // For now, return placeholder values return control_mod.QvlMetrics{ - .total_vertices = 0, - .total_edges = 0, + .total_vertices = self.risk_graph.nodeCount(), + .total_edges = self.risk_graph.edgeCount(), .trust_rank = 0.0, }; } diff --git a/capsule-core/src/qvl_store.zig b/capsule-core/src/qvl_store.zig index 1c1fc99..c7a3b23 100644 --- a/capsule-core/src/qvl_store.zig +++ b/capsule-core/src/qvl_store.zig @@ -22,6 +22,14 @@ const qvl_types = @import("qvl").types; pub const NodeId = qvl_types.NodeId; pub const RiskEdge = qvl_types.RiskEdge; +pub const StoredSlashEvent = struct { + timestamp: u64, + target_did: []const u8, + reason: []const u8, + severity: []const u8, + evidence_hash: []const u8, +}; + pub const QvlStore = struct { db: c.duckdb_database = null, conn: c.duckdb_connection = null, @@ -179,4 +187,63 @@ pub const QvlStore = struct { } c.duckdb_destroy_result(&res); } + + pub fn logSlashEvent( + self: *QvlStore, + timestamp: u64, + target_did: []const u8, + reason: []const u8, + severity: []const u8, + evidence_hash: []const u8, + ) !void { + var appender: c.duckdb_appender = null; + if (c.duckdb_appender_create(self.conn, null, "slash_events", &appender) != c.DuckDBSuccess) return error.ExecFailed; + defer _ = c.duckdb_appender_destroy(&appender); + + _ = c.duckdb_append_uint64(appender, timestamp); + _ = c.duckdb_append_varchar_length(appender, target_did.ptr, target_did.len); + _ = c.duckdb_append_varchar_length(appender, reason.ptr, reason.len); + _ = c.duckdb_append_varchar_length(appender, severity.ptr, severity.len); + _ = c.duckdb_append_varchar_length(appender, evidence_hash.ptr, evidence_hash.len); + _ = c.duckdb_appender_end_row(appender); + } + + pub fn getSlashEvents(self: *QvlStore, limit: usize) ![]StoredSlashEvent { + const sql_slice = try std.fmt.allocPrint(self.allocator, "SELECT timestamp, target_did, reason, severity, evidence_hash FROM slash_events ORDER BY timestamp DESC LIMIT {d};", .{limit}); + defer self.allocator.free(sql_slice); + const sql = try self.allocator.dupeZ(u8, sql_slice); + defer self.allocator.free(sql); + + var res: c.duckdb_result = undefined; + if (c.duckdb_query(self.conn, sql.ptr, &res) != c.DuckDBSuccess) { + std.log.err("DuckDB Slash Log Error: {s}", .{c.duckdb_result_error(&res)}); + c.duckdb_destroy_result(&res); + return error.QueryFailed; + } + defer c.duckdb_destroy_result(&res); + + const row_count = c.duckdb_row_count(&res); + var events = try self.allocator.alloc(StoredSlashEvent, row_count); + + for (0..row_count) |i| { + // Helper to get string safely + const getStr = struct { + fn get(result: *c.duckdb_result, row: u64, col: u64, allocator: std.mem.Allocator) ![]const u8 { + const val = c.duckdb_value_varchar(result, row, col); + defer c.duckdb_free(val); + return allocator.dupe(u8, std.mem.span(val)); + } + }.get; + + events[i] = StoredSlashEvent{ + .timestamp = c.duckdb_value_uint64(&res, i, 0), + .target_did = try getStr(&res, i, 1, self.allocator), + .reason = try getStr(&res, i, 2, self.allocator), + .severity = try getStr(&res, i, 3, self.allocator), + .evidence_hash = try getStr(&res, i, 4, self.allocator), + }; + } + + return events; + } }; diff --git a/capsule-core/src/storage.zig b/capsule-core/src/storage.zig index f97c6fe..16224ce 100644 --- a/capsule-core/src/storage.zig +++ b/capsule-core/src/storage.zig @@ -5,7 +5,7 @@ const std = @import("std"); const c = @cImport({ @cInclude("sqlite3.h"); }); -const dht = @import("dht.zig"); +const dht = @import("dht"); pub const RemoteNode = dht.RemoteNode; pub const ID_LEN = dht.ID_LEN; @@ -95,7 +95,7 @@ pub const StorageService = struct { _ = c.sqlite3_bind_blob(stmt, 1, &node.id, @intCast(node.id.len), null); // Bind Address - var addr_buf: [64]u8 = undefined; + var addr_buf: [1024]u8 = undefined; const addr_str = try std.fmt.bufPrintZ(&addr_buf, "{any}", .{node.address}); _ = c.sqlite3_bind_text(stmt, 2, addr_str.ptr, -1, null); diff --git a/capsule-core/src/tui/app.zig b/capsule-core/src/tui/app.zig new file mode 100644 index 0000000..e84d3c6 --- /dev/null +++ b/capsule-core/src/tui/app.zig @@ -0,0 +1,16 @@ +//! Capsule TUI Application (Stub) +//! Vaxis dependency temporarily removed to fix build. + +const std = @import("std"); + +pub const App = struct { + pub fn run(_: *anyopaque) !void { + std.log.info("TUI functionality temporarily disabled.", .{}); + } +}; + +pub fn run(allocator: std.mem.Allocator, control_socket_path: []const u8) !void { + _ = allocator; + _ = control_socket_path; + std.log.info("TUI functionality temporarily disabled.", .{}); +} diff --git a/capsule-core/src/tui/app.zig.bak b/capsule-core/src/tui/app.zig.bak new file mode 100644 index 0000000..46c3934 --- /dev/null +++ b/capsule-core/src/tui/app.zig.bak @@ -0,0 +1,167 @@ +//! Capsule TUI Application +//! Built with Vaxis (The "Luxury Deck"). + +const std = @import("std"); +const vaxis = @import("vaxis"); + +const control = @import("../control.zig"); +const client_mod = @import("client.zig"); +const view_mod = @import("view.zig"); + +const Event = union(enum) { + key_press: vaxis.Key, + winsize: vaxis.Winsize, + update_data: void, +}; + +pub const AppState = struct { + allocator: std.mem.Allocator, + should_quit: bool, + client: client_mod.Client, + + // UI State + active_tab: enum { Dashboard, SlashLog, TrustGraph } = .Dashboard, + + // Data State + node_status: ?client_mod.NodeStatus = null, + slash_log: std.ArrayList(client_mod.SlashEvent), + topology: ?client_mod.TopologyInfo = null, + + pub fn init(allocator: std.mem.Allocator) !AppState { + return .{ + .allocator = allocator, + .should_quit = false, + .client = try client_mod.Client.init(allocator), + .slash_log = std.ArrayList(client_mod.SlashEvent){}, + .topology = null, + }; + } + + pub fn deinit(self: *AppState) void { + self.client.deinit(); + if (self.node_status) |s| { + // Free strings in status if any? NodeStatus fields are slices. + // Client parser allocates them. We own them. + // We should free them. + // For now, simpler leak or arena. (TODO: correct cleanup) + _ = s; + } + for (self.slash_log.items) |ev| { + self.allocator.free(ev.target_did); + self.allocator.free(ev.reason); + self.allocator.free(ev.severity); + self.allocator.free(ev.evidence_hash); + } + self.slash_log.deinit(self.allocator); + } +}; + +pub fn run(allocator: std.mem.Allocator) !void { + var app = try AppState.init(allocator); + defer app.deinit(); + + // Initialize Vaxis + var vx = try vaxis.init(allocator, .{}); + // Initialize TTY + var tty = try vaxis.Tty.init(&.{}); // Use empty buffer (vaxis manages its own if needed, or this is for buffered read?) + defer tty.deinit(); + + defer vx.deinit(allocator, tty.writer()); // Reset terminal + + // Event Loop + var loop: vaxis.Loop(Event) = .{ .vaxis = &vx, .tty = &tty }; + try loop.init(); + try loop.start(); + defer loop.stop(); + + // Connect to Daemon + try app.client.connect(); + + // Spawn Data Thread + const DataThread = struct { + fn run(l: *vaxis.Loop(Event), a: *AppState) void { + while (!a.should_quit) { + // Poll Status + if (a.client.getStatus()) |status| { + if (a.node_status) |old| { + // Free old strings + a.allocator.free(old.node_id); + a.allocator.free(old.state); + a.allocator.free(old.version); + } + a.node_status = status; + } else |_| {} + + // Poll Slash Log + if (a.client.getSlashLog(20)) |logs| { + // Logs are new allocations. Replace list. + for (a.slash_log.items) |ev| { + a.allocator.free(ev.target_did); + a.allocator.free(ev.reason); + a.allocator.free(ev.severity); + a.allocator.free(ev.evidence_hash); + } + a.slash_log.clearRetainingCapacity(); + a.slash_log.appendSlice(a.allocator, logs) catch {}; + a.allocator.free(logs); // Free the slice itself (deep copy helper allocated slice) + } else |_| {} + + if (a.client.getTopology()) |topo| { + if (a.topology) |old| { + // Free old + // TODO: Implement deep free or rely on allocator arena if we had one. + // For now we leak old topology strings if not careful. + // Ideally we should free the old one using a helper. + // But since we use a shared allocator, we should be careful. + // Given this is a TUI, we might accept some leakage for MVP or fix it properly. + // Let's rely on OS cleanup for now or implement freeTopology + _ = old; + } + a.topology = topo; + } else |_| {} + + // Notify UI to redraw + l.postEvent(.{ .update_data = {} }); + + std.Thread.sleep(1 * std.time.ns_per_s); + } + } + }; + + var thread = try std.Thread.spawn(.{}, DataThread.run, .{ &loop, &app }); + defer thread.join(); + + while (!app.should_quit) { + // Handle Events + const event = loop.nextEvent(); + switch (event) { + .key_press => |key| { + if (key.matches('c', .{ .ctrl = true }) or key.matches('q', .{})) { + app.should_quit = true; + } + // Handle tab switching + if (key.matches(vaxis.Key.tab, .{})) { + app.active_tab = switch (app.active_tab) { + .Dashboard => .SlashLog, + .SlashLog => .TrustGraph, + .TrustGraph => .Dashboard, + }; + } + }, + .winsize => |ws| { + try vx.resize(allocator, tty.writer(), ws); + }, + .update_data => { + // Just trigger render + }, + } + + // Render + const win = vx.window(); + win.clear(); + + try view_mod.draw(&app, win); + + try vx.render(tty.writer()); + } +} diff --git a/capsule-core/src/tui/client.zig b/capsule-core/src/tui/client.zig new file mode 100644 index 0000000..16ab4a5 --- /dev/null +++ b/capsule-core/src/tui/client.zig @@ -0,0 +1,137 @@ +//! IPC Client for TUI -> Daemon communication. +//! Wraps control.zig types. + +const std = @import("std"); +const control = @import("../control.zig"); + +pub const NodeStatus = control.NodeStatus; +pub const SlashEvent = control.SlashEvent; +pub const TopologyInfo = control.TopologyInfo; +pub const GraphNode = control.GraphNode; +pub const GraphEdge = control.GraphEdge; + +pub const Client = struct { + allocator: std.mem.Allocator, + stream: ?std.net.Stream = null, + + pub fn init(allocator: std.mem.Allocator) !Client { + return .{ + .allocator = allocator, + }; + } + + pub fn deinit(self: *Client) void { + if (self.stream) |s| s.close(); + } + + pub fn connect(self: *Client) !void { + // Connect to /tmp/capsule.sock + // TODO: Load from config + const path = "/tmp/capsule.sock"; + const address = try std.net.Address.initUnix(path); + self.stream = try std.net.tcpConnectToAddress(address); + } + + pub fn getStatus(self: *Client) !NodeStatus { + const resp = try self.request(.Status); + switch (resp) { + .NodeStatus => |s| return s, + else => return error.UnexpectedResponse, + } + } + + pub fn getSlashLog(self: *Client, limit: usize) ![]SlashEvent { + const resp = try self.request(.{ .SlashLog = .{ .limit = limit } }); + switch (resp) { + .SlashLogResult => |l| { + // We need to duplicate the list because response memory is transient (if using an arena in request) + // But for now, let's assume the caller handles it or we deep copy. + // Simpler: Return generic Response and let caller handle. + // Actually, let's just return the slice and hope the buffer lifetime management in request isn't too tricky. + // Wait, request() will likely use a local buffer. Returning a slice into it is unsafe. + // I need to use an arena or return a deep copy. + // For this MVP, I'll return the response object completely if possible, or copy. + // Let's implement deep copy later. For now, assume single-threaded blocking. + return try self.deepCopySlashLog(l); + }, + else => return error.UnexpectedResponse, + } + } + + pub fn request(self: *Client, cmd: control.Command) !control.Response { + if (self.stream == null) return error.NotConnected; + const stream = self.stream.?; + + // Send + var req_buf = std.ArrayList(u8){}; + defer req_buf.deinit(self.allocator); + var w_struct = req_buf.writer(self.allocator); + var buffer: [128]u8 = undefined; + var adapter = w_struct.adaptToNewApi(&buffer); + try std.json.Stringify.value(cmd, .{}, &adapter.new_interface); + try adapter.new_interface.flush(); + try stream.writeAll(req_buf.items); + + // Read (buffered) + var resp_buf: [32768]u8 = undefined; // Large buffer for slash log + const bytes = try stream.read(&resp_buf); + if (bytes == 0) return error.ConnectionClosed; + + // Parse (using allocator for string allocations inside union) + const parsed = try std.json.parseFromSlice(control.Response, self.allocator, resp_buf[0..bytes], .{ .ignore_unknown_fields = true }); + // Note: parsed.value contains pointers to resp_buf if we used Leaky, but here we used allocator. + // Wait, std.json.parseFromSlice with allocator allocates strings! + // So we can return parsed.value. + return parsed.value; + } + + pub fn getTopology(self: *Client) !TopologyInfo { + const resp = try self.request(.Topology); + switch (resp) { + .TopologyInfo => |t| return try self.deepCopyTopology(t), + else => return error.UnexpectedResponse, + } + } + + fn deepCopySlashLog(self: *Client, events: []const SlashEvent) ![]SlashEvent { + const list = try self.allocator.alloc(SlashEvent, events.len); + for (events, 0..) |ev, i| { + list[i] = .{ + .timestamp = ev.timestamp, + .target_did = try self.allocator.dupe(u8, ev.target_did), + .reason = try self.allocator.dupe(u8, ev.reason), + .severity = try self.allocator.dupe(u8, ev.severity), + .evidence_hash = try self.allocator.dupe(u8, ev.evidence_hash), + }; + } + return list; + } + + fn deepCopyTopology(self: *Client, topo: TopologyInfo) !TopologyInfo { + // Deep copy nodes + const nodes = try self.allocator.alloc(control.GraphNode, topo.nodes.len); + for (topo.nodes, 0..) |n, i| { + nodes[i] = .{ + .id = try self.allocator.dupe(u8, n.id), + .trust_score = n.trust_score, + .status = try self.allocator.dupe(u8, n.status), + .role = try self.allocator.dupe(u8, n.role), + }; + } + + // Deep copy edges + const edges = try self.allocator.alloc(control.GraphEdge, topo.edges.len); + for (topo.edges, 0..) |e, i| { + edges[i] = .{ + .source = try self.allocator.dupe(u8, e.source), + .target = try self.allocator.dupe(u8, e.target), + .weight = e.weight, + }; + } + + return TopologyInfo{ + .nodes = nodes, + .edges = edges, + }; + } +}; diff --git a/capsule-core/src/tui/view.zig b/capsule-core/src/tui/view.zig new file mode 100644 index 0000000..c1b970f --- /dev/null +++ b/capsule-core/src/tui/view.zig @@ -0,0 +1,174 @@ +//! View Logic for Capsule TUI +//! Renders the "Luxury Deck" interface. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const app_mod = @import("app.zig"); + +pub fn draw(app: *app_mod.AppState, win: vaxis.Window) !void { + // 1. Draw Header + const header = win.child(.{ + .x_off = 0, + .y_off = 0, + .width = win.width, + .height = 3, + }); + header.fill(vaxis.Cell{ .style = .{ .bg = .{ .rgb = .{ 20, 20, 30 } } } }); + + _ = header.printSegment(.{ .text = " CAPSULE OS ", .style = .{ .fg = .{ .rgb = .{ 255, 215, 0 } }, .bold = true } }, .{ .row_offset = 1, .col_offset = 2 }); + + // Tabs + const tabs = [_][]const u8{ "Dashboard", "Slash Log", "Trust Graph" }; + var col: usize = 20; + for (tabs, 0..) |tab, i| { + const is_active = i == @intFromEnum(app.active_tab); + const style: vaxis.Style = if (is_active) + .{ .fg = .{ .rgb = .{ 255, 255, 255 } }, .bg = .{ .rgb = .{ 60, 60, 80 } }, .bold = true } + else + .{ .fg = .{ .rgb = .{ 150, 150, 150 } } }; + + _ = header.printSegment(.{ .text = tab, .style = style }, .{ .row_offset = 1, .col_offset = @intCast(col) }); + col += tab.len + 4; + } + + // 2. Draw Content Area + const content = win.child(.{ + .x_off = 0, + .y_off = 3, + .width = win.width, + .height = win.height - 3, + }); + + switch (app.active_tab) { + .Dashboard => try drawDashboard(app, content), + .SlashLog => try drawSlashLog(app, content), + .TrustGraph => try drawTrustGraph(app, content), + } +} + +fn drawDashboard(app: *app_mod.AppState, win: vaxis.Window) !void { + if (app.node_status) |status| { + // Node ID + var buf: [128]u8 = undefined; + const id_str = try std.fmt.bufPrint(&buf, "Node ID: {s}", .{status.node_id}); + _ = win.printSegment(.{ .text = id_str, .style = .{ .fg = .{ .rgb = .{ 100, 200, 100 } } } }, .{ .row_offset = 1, .col_offset = 2 }); + + // State + const state_str = try std.fmt.bufPrint(&buf, "State: {s}", .{status.state}); + _ = win.printSegment(.{ .text = state_str }, .{ .row_offset = 2, .col_offset = 2 }); + + // Version + const ver_str = try std.fmt.bufPrint(&buf, "Version: {s}", .{status.version}); + _ = win.printSegment(.{ .text = ver_str }, .{ .row_offset = 3, .col_offset = 2 }); + + // Peers + const peers_str = try std.fmt.bufPrint(&buf, "Peers: {}", .{status.peers_count}); + _ = win.printSegment(.{ .text = peers_str }, .{ .row_offset = 4, .col_offset = 2 }); + } else { + _ = win.printSegment(.{ .text = "Fetching status...", .style = .{ .fg = .{ .rgb = .{ 150, 150, 150 } } } }, .{ .row_offset = 2, .col_offset = 2 }); + } +} + +fn drawSlashLog(app: *app_mod.AppState, win: vaxis.Window) !void { + // Header + _ = win.printSegment(.{ .text = "Target DID", .style = .{ .bold = true, .ul_style = .single } }, .{ .row_offset = 1, .col_offset = 2 }); + _ = win.printSegment(.{ .text = "Reason", .style = .{ .bold = true, .ul_style = .single } }, .{ .row_offset = 1, .col_offset = 40 }); + _ = win.printSegment(.{ .text = "Severity", .style = .{ .bold = true, .ul_style = .single } }, .{ .row_offset = 1, .col_offset = 70 }); + + var row: u16 = 2; + for (app.slash_log.items) |ev| { + if (row >= win.height) break; + + _ = win.printSegment(.{ .text = ev.target_did }, .{ .row_offset = row, .col_offset = 2 }); + _ = win.printSegment(.{ .text = ev.reason }, .{ .row_offset = row, .col_offset = 40 }); + _ = win.printSegment(.{ .text = ev.severity }, .{ .row_offset = row, .col_offset = 70 }); + + row += 1; + } + + if (app.slash_log.items.len == 0) { + _ = win.printSegment(.{ .text = "No slash events recorded.", .style = .{ .fg = .{ .rgb = .{ 100, 100, 100 } } } }, .{ .row_offset = 3, .col_offset = 2 }); + } +} + +fn drawTrustGraph(app: *app_mod.AppState, win: vaxis.Window) !void { + // 1. Draw Title + _ = win.printSegment(.{ .text = "QVL TRUST LATTICE", .style = .{ .bold = true, .fg = .{ .rgb = .{ 100, 255, 255 } } } }, .{ .row_offset = 1, .col_offset = 2 }); + + if (app.topology) |topo| { + // Center of the radar + const cx: usize = win.width / 2; + const cy: usize = win.height / 2; + + // Max radius (smaller of width/height / 2, minus margin) + const max_radius = @min(cx, cy) - 2; + + // Draw Rings (Orbits) + // 25%, 50%, 75%, 100% Trust + // Cannot draw circles easily with characters, so we just imply them by node position + // Or we could draw axes. Let's draw axes. + + // X-Axis + // for (2..win.width-2) |x| { + // _ = win.printSegment(.{ .text = "-", .style = .{ .fg = .{ .rgb = .{ 60, 60, 60 } } } }, .{ .row_offset = @intCast(cy), .col_offset = @intCast(x) }); + // } + // Y-Axis + // for (2..win.height-1) |y| { + // _ = win.printSegment(.{ .text = "|", .style = .{ .fg = .{ .rgb = .{ 60, 60, 60 } } } }, .{ .row_offset = @intCast(y), .col_offset = @intCast(cx) }); + // } + + // Draw Nodes + const nodes_count = topo.nodes.len; + // Skip self (index 0) loop for now to draw it specially at center + + // Self + _ = win.printSegment(.{ .text = "★", .style = .{ .bold = true, .fg = .{ .rgb = .{ 255, 215, 0 } } } }, .{ .row_offset = @intCast(cy), .col_offset = @intCast(cx) }); + _ = win.printSegment(.{ .text = "SELF" }, .{ .row_offset = @intCast(cy + 1), .col_offset = @intCast(cx - 2) }); + + // Peers + // We will distribute them by angle (index) and radius (1.0 - trust) + // Trust 1.0 = Center (0 radius) + // Trust 0.0 = Edge (max radius) + + const count_f: f64 = @floatFromInt(nodes_count); + + for (topo.nodes, 0..) |node, i| { + if (i == 0) continue; // Skip self + + const angle = (2.0 * std.math.pi * @as(f64, @floatFromInt(i))) / count_f; + const dist_factor = 1.0 - node.trust_score; // Higher trust = closer to center + const radius = dist_factor * @as(f64, @floatFromInt(max_radius)); + + // Polar to Cartesian + const dx = @cos(angle) * (radius * 2.0); // *2 for aspect ratio correction (roughly) + const dy = @sin(angle) * radius; + + const px: usize = @intCast(@as(i64, @intCast(cx)) + @as(i64, @intFromFloat(dx))); + const py: usize = @intCast(@as(i64, @intCast(cy)) + @as(i64, @intFromFloat(dy))); + + // Bound check + if (px > 0 and px < win.width and py > 0 and py < win.height) { + // Style based on status + var style: vaxis.Style = .{ .fg = .{ .rgb = .{ 200, 200, 200 } } }; + var char: []const u8 = "o"; + + if (std.mem.eql(u8, node.status, "slashed")) { + style = .{ .fg = .{ .rgb = .{ 255, 50, 50 } }, .bold = true, .blink = true }; + char = "X"; + } else if (node.trust_score > 0.8) { + style = .{ .fg = .{ .rgb = .{ 100, 255, 100 } }, .bold = true }; + char = "⬢"; + } + + _ = win.printSegment(.{ .text = char, .style = style }, .{ .row_offset = @intCast(py), .col_offset = @intCast(px) }); + + // Label (ID) + if (win.width > 60) { + _ = win.printSegment(.{ .text = node.id, .style = .{ .dim = true } }, .{ .row_offset = @intCast(py + 1), .col_offset = @intCast(px) }); + } + } + } + } else { + _ = win.printSegment(.{ .text = "Waiting for Topology Data...", .style = .{ .blink = true } }, .{ .row_offset = 2, .col_offset = 4 }); + } +} diff --git a/capsule-core/src/dht.zig b/l0-transport/dht.zig similarity index 92% rename from capsule-core/src/dht.zig rename to l0-transport/dht.zig index 6515795..414f1e5 100644 --- a/capsule-core/src/dht.zig +++ b/l0-transport/dht.zig @@ -121,6 +121,14 @@ pub const RoutingTable = struct { @memcpy(out, results.items[0..actual_count]); return out; } + + pub fn getNodeCount(self: *const RoutingTable) usize { + var count: usize = 0; + for (self.buckets) |bucket| { + count += bucket.nodes.items.len; + } + return count; + } }; pub const DhtService = struct { @@ -137,4 +145,8 @@ pub const DhtService = struct { pub fn deinit(self: *DhtService) void { self.routing_table.deinit(); } + + pub fn getKnownNodeCount(self: *const DhtService) usize { + return self.routing_table.getNodeCount(); + } }; diff --git a/l0-transport/gateway.zig b/l0-transport/gateway.zig new file mode 100644 index 0000000..6ac5cfe --- /dev/null +++ b/l0-transport/gateway.zig @@ -0,0 +1,100 @@ +//! RFC-0018: Gateway Protocol +//! +//! layer 1: Coordination Layer +//! Handles NAT hole punching, peer discovery, and relay announcements. +//! Gateways do NOT forward data traffic. + +const std = @import("std"); +const dht = @import("dht"); + +pub const Gateway = struct { + allocator: std.mem.Allocator, + + // DHT for peer discovery + dht_service: *dht.DhtService, + + // In-memory address registry (PeerID -> Public Address) + // This is a fast lookup for connected peers or those recently announced. + peer_addresses: std.AutoHashMap(dht.NodeId, std.net.Address), + + pub fn init(allocator: std.mem.Allocator, dht_service: *dht.DhtService) Gateway { + return Gateway{ + .allocator = allocator, + .dht_service = dht_service, + .peer_addresses = std.AutoHashMap(dht.NodeId, std.net.Address).init(allocator), + }; + } + + pub fn deinit(self: *Gateway) void { + self.peer_addresses.deinit(); + } + + /// Register a peer's public address + pub fn registerPeer(self: *Gateway, peer_id: dht.NodeId, addr: std.net.Address) !void { + // Store in local cache + try self.peer_addresses.put(peer_id, addr); + + // Announce to DHT (Store operations would go here) + // For now, we update the local routing table if appropriate, + // but typically a Gateway *stores* values for others. + // The current DhtService implementation is basic (RoutingTable only). + // We'll treat the routing table as the primary storage for "live" nodes. + const remote = dht.RemoteNode{ + .id = peer_id, + .address = addr, + .last_seen = std.time.milliTimestamp(), + }; + try self.dht_service.routing_table.update(remote); + } + + /// STUN-like coordination for hole punching + pub fn coordinateHolePunch( + self: *Gateway, + peer_a: dht.NodeId, + peer_b: dht.NodeId, + ) !HolePunchCoordination { + const addr_a = self.peer_addresses.get(peer_a) orelse return error.PeerNotFound; + const addr_b = self.peer_addresses.get(peer_b) orelse return error.PeerNotFound; + + return HolePunchCoordination{ + .peer_a_addr = addr_a, + .peer_b_addr = addr_b, + .timestamp = @intCast(std.time.timestamp()), + }; + } +}; + +pub const HolePunchCoordination = struct { + peer_a_addr: std.net.Address, + peer_b_addr: std.net.Address, + timestamp: u64, +}; + +test "Gateway: register and coordinate" { + const allocator = std.testing.allocator; + + var self_id = [_]u8{0} ** 32; + self_id[0] = 1; + + var dht_svc = dht.DhtService.init(allocator, self_id); + defer dht_svc.deinit(); + + var gw = Gateway.init(allocator, &dht_svc); + defer gw.deinit(); + + var peer_a_id = [_]u8{0} ** 32; + peer_a_id[0] = 0xAA; + var peer_b_id = [_]u8{0} ** 32; + peer_b_id[0] = 0xBB; + + const addr_a = try std.net.Address.parseIp("1.2.3.4", 8080); + const addr_b = try std.net.Address.parseIp("5.6.7.8", 9090); + + try gw.registerPeer(peer_a_id, addr_a); + try gw.registerPeer(peer_b_id, addr_b); + + const coord = try gw.coordinateHolePunch(peer_a_id, peer_b_id); + + try std.testing.expect(coord.peer_a_addr.eql(addr_a)); + try std.testing.expect(coord.peer_b_addr.eql(addr_b)); +} diff --git a/l0-transport/relay.zig b/l0-transport/relay.zig new file mode 100644 index 0000000..33f14af --- /dev/null +++ b/l0-transport/relay.zig @@ -0,0 +1,153 @@ +//! RFC-0018: Relay Protocol (Layer 2) +//! +//! Implements onion-routed packet forwarding. +//! +//! Packet Structure (Conceptual Onion): +//! [ Next Hop: R1 | Encrypted Payload for R1 [ Next Hop: R2 | Encrypted Payload for R2 [ Target: B | Payload ] ] ] +//! +//! For Phase 13 (Week 34), we implement the packet framing and wrapping logic. +//! We assume shared secrets are established via the Federation Handshake (or Prekey bundles). + +const std = @import("std"); +const crypto = @import("std").crypto; +const net = std.net; + +/// Fixed packet size to mitigate side-channel analysis (size correlation). +/// Real-world implementation might use 4KB or 1KB chunks. +pub const RELAY_PACKET_SIZE = 1024 + 128; // Payload + Headers + +pub const RelayError = error{ + PacketTooLarge, + DecryptionFailed, + InvalidNextHop, + HopLimitExceeded, +}; + +/// The routing header visible to the current relay after decryption. +pub const NextHopHeader = struct { + next_hop_id: [32]u8, // NodeID (0x00... for exit/final destination) + // We might add HMAC or integrity check here +}; + +/// A Relay Packet as it travels on the wire. +/// It effectively contains an encrypted blob that the receiver can decrypt +/// to reveal the NextHopHeader and the inner Payload. +pub const RelayPacket = struct { + // Public ephemeral key for ECDH could be here if we do per-packet keying, + // but typically we use established session keys or pre-keys. + // For simplicity V1, we assume a session key exists or use a nonce. + + nonce: [24]u8, // XChaCha20 nonce + ciphertext: []u8, // Encrypted [NextHopHeader + InnerPayload] + + pub fn init(allocator: std.mem.Allocator, size: usize) !RelayPacket { + return RelayPacket{ + .nonce = undefined, // To be filled + .ciphertext = try allocator.alloc(u8, size), + }; + } + + pub fn deinit(self: *RelayPacket, allocator: std.mem.Allocator) void { + allocator.free(self.ciphertext); + } +}; + +/// Logic to construct an onion packet. +pub const OnionBuilder = struct { + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) OnionBuilder { + return .{ + .allocator = allocator, + }; + } + + /// Wraps a payload into a single layer of encryption for a specific relay. + /// In a real onion, this is called iteratively from innermost to outermost. + pub fn wrapLayer( + self: *OnionBuilder, + payload: []const u8, + next_hop: [32]u8, + shared_secret: [32]u8, + ) !RelayPacket { + _ = shared_secret; + // 1. Construct Cleartext: [NextHop (32) | Payload (N)] + var cleartext = try self.allocator.alloc(u8, 32 + payload.len); + defer self.allocator.free(cleartext); + + @memcpy(cleartext[0..32], &next_hop); + @memcpy(cleartext[32..], payload); + + // 2. Encrypt + var packet = try RelayPacket.init(self.allocator, cleartext.len + 16); // +AuthTag + crypto.random.bytes(&packet.nonce); + + // Mock Encryption (XChaCha20-Poly1305 would go here) + // For MVP structure, we just copy (TODO: Add actual crypto integration) + // We simulate "encryption" by XORing with a byte for testing proving modification works + for (cleartext, 0..) |b, i| { + packet.ciphertext[i] = b ^ 0xFF; // Simple NOT for mock encryption + } + // Mock Auth Tag + @memset(packet.ciphertext[cleartext.len..], 0xAA); + + return packet; + } + + /// Unwraps a single layer (Server/Relay side logic). + pub fn unwrapLayer( + self: *OnionBuilder, + packet: RelayPacket, + shared_secret: [32]u8, + ) !struct { next_hop: [32]u8, payload: []u8 } { + _ = shared_secret; + + // Mock Decryption + if (packet.ciphertext.len < 32 + 16) return error.DecryptionFailed; + + const content_len = packet.ciphertext.len - 16; + var cleartext = try self.allocator.alloc(u8, content_len); + + for (0..content_len) |i| { + cleartext[i] = packet.ciphertext[i] ^ 0xFF; + } + + var next_hop: [32]u8 = undefined; + @memcpy(&next_hop, cleartext[0..32]); + + // Move payload to a new buffer to shrink + const payload_len = content_len - 32; + const payload = try self.allocator.alloc(u8, payload_len); + @memcpy(payload, cleartext[32..]); + + self.allocator.free(cleartext); + + return .{ + .next_hop = next_hop, + .payload = payload, + }; + } +}; + +test "Relay: wrap and unwrap" { + const allocator = std.testing.allocator; + var builder = OnionBuilder.init(allocator); + + const payload = "Hello Onion!"; + const next_hop = [_]u8{0xAB} ** 32; + const shared_secret = [_]u8{0} ** 32; + + var packet = try builder.wrapLayer(payload, next_hop, shared_secret); + defer packet.deinit(allocator); + + // Verify it is "encrypted" (XOR 0xFF) + // Payload "H" (0x48) ^ 0xFF = 0xB7 + // First byte of cleartext is next_hop[0] (0xAB) ^ 0xFF = 0x54 + try std.testing.expectEqual(@as(u8, 0x54), packet.ciphertext[0]); + + const result = try builder.unwrapLayer(packet, shared_secret); + defer allocator.free(result.payload); + + try std.testing.expectEqualSlices(u8, &next_hop, &result.next_hop); + try std.testing.expectEqualSlices(u8, payload, result.payload); +} diff --git a/root b/root new file mode 100755 index 0000000..0a800e4 Binary files /dev/null and b/root differ diff --git a/scripts/build_container.sh b/scripts/build_container.sh new file mode 100755 index 0000000..4461e2d --- /dev/null +++ b/scripts/build_container.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -e + +# Build +echo "Building Wolfi container..." +podman build -f Containerfile.wolfi -t capsule-wolfi . + +# Run +echo "Running Capsule Node in Wolfi container..." +mkdir -p data-container +# Note: we override the CMD to pass arguments +podman run -d --rm --network host --name capsule-wolfi \ + -v $(pwd)/data-container:/app/data \ + capsule-wolfi \ + ./zig-out/bin/capsule start --port 9001 --data-dir /app/data diff --git a/scripts/build_fast.sh b/scripts/build_fast.sh new file mode 100755 index 0000000..5bf8582 --- /dev/null +++ b/scripts/build_fast.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +echo "Building capsule on host..." +cd capsule-core +zig build +cd .. + +echo "Preparing libs..." +mkdir -p libs +cp /usr/lib/libduckdb.so libs/ + +echo "Building Fast-Track container..." +podman build --platform linux/amd64 -f Containerfile.fast -t capsule-wolfi . + +echo "Running Capsule Node in Fast-Track container..." +mkdir -p /tmp/libertaria-container-data +podman run -d --rm --network host --name capsule-wolfi \ + -v "/tmp/libertaria-container-data:/app/data" \ + -v "$(pwd)/capsule-core/config.json:/app/config.json" \ + capsule-wolfi \ + capsule start --port 9001 --data-dir /app/data