Compare commits
14 Commits
master
...
7c97ad554e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c97ad554e | |||
| a8ff9d95bb | |||
| 2688cacfa7 | |||
| 0a7ad2662c | |||
| 88d312e713 | |||
| d70a051a57 | |||
| b1bdfccd1e | |||
| 84c4158aaa | |||
| 250829e922 | |||
| f902086e14 | |||
| 569700d6bd | |||
| a7aa1517ca | |||
| aa3b77eeda | |||
| 7d85821643 |
84
.gitignore
vendored
84
.gitignore
vendored
@@ -1,83 +1,15 @@
|
||||
# ---> Zig
|
||||
zig-cache/
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
zig-pkg
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
build/
|
||||
build-*/
|
||||
docgen_tmp/
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
.zbscanhelper.zig
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
.idea/
|
||||
|
||||
config/*.md
|
||||
|
||||
92
build.zig
Normal file
92
build.zig
Normal file
@@ -0,0 +1,92 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void
|
||||
{
|
||||
const target = b.standardTargetOptions(.{
|
||||
// .default_target = .{ .abi = .musl },
|
||||
});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const version = getGitVersion(b);
|
||||
|
||||
const options = b.addOptions();
|
||||
options.addOption([]const u8, "version", version);
|
||||
|
||||
// deps
|
||||
|
||||
const toml_dep = b.dependency("toml", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
const args_dep = b.dependency("args", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
const mod = b.addModule("zocket", .{
|
||||
.root_source_file = b.path("src/root.zig"),
|
||||
.target = target,
|
||||
});
|
||||
|
||||
mod.addImport("toml", toml_dep.module("toml"));
|
||||
mod.addImport("args", args_dep.module("args"));
|
||||
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "zocket",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "zocket", .module = mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// exe.root_module.link_libc = true;
|
||||
exe.root_module.addOptions("build_options", options);
|
||||
|
||||
//
|
||||
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_step = b.step("run", "Run the app");
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
run_cmd.step.dependOn(b.getInstallStep());
|
||||
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
const mod_tests = b.addTest(.{
|
||||
.root_module = mod,
|
||||
});
|
||||
|
||||
const run_mod_tests = b.addRunArtifact(mod_tests);
|
||||
|
||||
const exe_tests = b.addTest(.{
|
||||
.root_module = exe.root_module,
|
||||
});
|
||||
|
||||
const run_exe_tests = b.addRunArtifact(exe_tests);
|
||||
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&run_mod_tests.step);
|
||||
test_step.dependOn(&run_exe_tests.step);
|
||||
}
|
||||
|
||||
fn getGitVersion(b: *std.Build) []const u8
|
||||
{
|
||||
const result = b.runAllowFail(
|
||||
&.{ "git", "describe", "--tags", "--always", "--dirty" },
|
||||
undefined,
|
||||
.inherit,
|
||||
) catch return "0.0.0-unknown";
|
||||
|
||||
return b.dupe(std.mem.trim(u8, result, " \n\r\t"));
|
||||
}
|
||||
23
build.zig.zon
Normal file
23
build.zig.zon
Normal file
@@ -0,0 +1,23 @@
|
||||
.{
|
||||
.name = .zocket,
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0x22ea3973fd74773f,
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
.toml = .{
|
||||
.url = "git+https://github.com/sam701/zig-toml?ref=zig-0.16#8685923e32e8b8a795eb2715684236975a70faed",
|
||||
.hash = "toml-0.3.0-bV14BRmKAQAWR0FT0KUKFwVJTImaFhWjSe6HfSMtNZOH",
|
||||
},
|
||||
.args = .{
|
||||
.url = "https://github.com/muhammad-fiaz/args.zig/archive/refs/tags/0.0.8.tar.gz",
|
||||
.hash = "args-0.0.8-8hzgbsBECgD3C0uPaFNcK9UFTZ210rWU4XIhopR8snoA",
|
||||
},
|
||||
},
|
||||
.paths = .{
|
||||
"build.zig",
|
||||
"build.zig.zon",
|
||||
"src",
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
},
|
||||
}
|
||||
137
config/config.toml
Normal file
137
config/config.toml
Normal file
@@ -0,0 +1,137 @@
|
||||
[node]
|
||||
id = "node-01"
|
||||
data_dir = "/var/lib/zocket" # /$data_dir/$id/*
|
||||
private_key = "/var/lib/zocket/node-01/identity.key" # ed25519, auto-generated on first run if missing, default = /$data_dir/$id/identity.key
|
||||
# DEFAULT ASSUMPTION: ephemeral.
|
||||
# A fresh keypair each restart is fine and expected as nothing is persisted to disk on purpose
|
||||
# (see mesh.discoverability + mesh.trust below), everything mesh-wide is rediscovered via
|
||||
# gossip/broadcast after (re)connecting to a seed peer in mesh.peers.
|
||||
# EXCEPTION:
|
||||
# If another node pins THIS node by pubkey in its own static mesh.peers[] entry, this
|
||||
# key must be persisted (mount on a volume) as a pinned pubkey is a promise of a stable identity,
|
||||
# and an ephemeral key breaks that pin on every restart.
|
||||
# Only relevant for statically pinned, long-lived "hub" style nodes; auto-enrolled/broadcast-mode peers never need this.
|
||||
|
||||
[log]
|
||||
level = "info" # debug | info | warn | error
|
||||
format = "json" # json | text
|
||||
output = "stdout" # stdout | /path/to/file.log
|
||||
|
||||
# ----
|
||||
|
||||
[listen.client]
|
||||
enabled = true
|
||||
bind = "0.0.0.0:8080"
|
||||
|
||||
[listen.client.tls]
|
||||
enabled = false
|
||||
cert = "/etc/zocket/client.crt"
|
||||
key = "/etc/zocket/client.key"
|
||||
|
||||
[listen.client.auth]
|
||||
mode = "token" # none | token
|
||||
# If mode is set to "token", then the client_id is DERIVED from the matched
|
||||
# identity's id below, never self-declared by the client.
|
||||
# If mode is set to "none", any client may self-declare their client_id.
|
||||
# On collision with an already-connected client_id, the NEW connection is rejected
|
||||
# (first-connected wins) to prevent a second client from silently taking over another's routing identity.
|
||||
|
||||
[[listen.client.auth.identities]]
|
||||
id = "discord-bot"
|
||||
token = "sdfb08zbd98bzbh9r98gh98enb"
|
||||
|
||||
[[listen.client.auth.identities]]
|
||||
id = "minecraft-mod"
|
||||
token = "sv8sovz987w98ruw9fwbgfubwf"
|
||||
|
||||
[listen.client.access_control]
|
||||
mode = "allowlist" # allowlist | blocklist | disabled
|
||||
list = ["10.0.0.0/24", "127.0.0.1"] # IP | CIDR
|
||||
|
||||
[listen.client.limits]
|
||||
max_message_bytes = 65536
|
||||
max_frame_bytes = 65536
|
||||
idle_timeout_secs = 300
|
||||
ping_interval_secs = 30
|
||||
max_missed_pongs = 2
|
||||
max_connections = 256
|
||||
|
||||
[listen.client.limits.rate_limit]
|
||||
enabled = true
|
||||
messages_per_sec = 50
|
||||
burst = 100
|
||||
|
||||
# ----
|
||||
|
||||
[listen.peer]
|
||||
enabled = true
|
||||
bind = "0.0.0.0:8181"
|
||||
|
||||
[listen.peer.tls]
|
||||
enabled = false
|
||||
cert = "/etc/zocket/peer.crt"
|
||||
key = "/etc/zocket/peer.key"
|
||||
require_client_cert = false
|
||||
|
||||
[listen.peer.auth]
|
||||
mode = "pubkey" # pubkey
|
||||
# Peer links can inject routes and broadcasts mesh-wide, so identity is always cryptographically verified, regardless of access_control.
|
||||
# Every connecting peer proves possession of the private key matching a pubkey via a signed nonce challenge - no shared secret ever transits the wire.
|
||||
# For a KNOWN node_id, the pubkey must match mesh.peers[].pubkey exactly, or the connection is rejected outright.
|
||||
# For a claimed node_id that isn't yet known, see mesh.discoverability = "broadcast" below.
|
||||
|
||||
[listen.peer.access_control]
|
||||
mode = "allowlist" # allowlist | blocklist | disabled
|
||||
list = ["10.0.0.0/24", "127.0.0.1"] # IP | CIDR
|
||||
|
||||
[listen.peer.limits]
|
||||
max_message_bytes = 1048576
|
||||
idle_timeout_secs = 60
|
||||
ping_interval_secs = 15
|
||||
|
||||
# ----
|
||||
|
||||
[mesh]
|
||||
enabled = true
|
||||
topology = "full" # full | static-partial | disabled
|
||||
routing_mode = "gossip" # gossip | reactive | static
|
||||
|
||||
[mesh.discoverability]
|
||||
mode = "static" # static | broadcast
|
||||
# static: mesh.peers below is the complete, manually-maintained set of instances.
|
||||
# broadcast: mesh.peers is just the initial seed; unknown nodes may connect and prove
|
||||
# possession of a pubkey; they're auto-trusted and gossiped mesh-wide immediately.
|
||||
# listen.peer.access_control is the real gate in this mode; scope it tightly.
|
||||
|
||||
[[mesh.peers]]
|
||||
id = "node-02"
|
||||
addr = "10.0.0.2:8181"
|
||||
pubkey = "rijgeb8u845un34tun34tou3nt3ot3untz3otn3to"
|
||||
reconnect = true
|
||||
backoff_min_ms = 500
|
||||
backoff_max_ms = 30000
|
||||
|
||||
[mesh.trust]
|
||||
gossip_new_peers = true # propagate newly-enrolled peer identities to the rest of the mesh
|
||||
|
||||
[mesh.gossip]
|
||||
seen_cache_ttl_secs = 300
|
||||
route_ttl_secs = 0 # 0 = no expiry, rely on explicit ROUTE_REMOVE
|
||||
|
||||
[mesh.broadcast]
|
||||
enabled = true
|
||||
# Broadcasting (target = "*") is peer-only and never exposed to clients on listen.client.
|
||||
# Floods across peer links using the same seen_cache dedup as gossip route announcements.
|
||||
|
||||
# only used if routing_mode = "reactive"
|
||||
[mesh.reactive]
|
||||
query_ttl_hops = 4
|
||||
query_timeout_ms = 2000
|
||||
negative_cache_secs = 30
|
||||
|
||||
# ----
|
||||
|
||||
# prometheus format
|
||||
[metrics]
|
||||
enabled = true
|
||||
bind = "127.0.0.1:9090"
|
||||
30
src/config/config.zig
Normal file
30
src/config/config.zig
Normal file
@@ -0,0 +1,30 @@
|
||||
const Config = @import("models.zig").Config;
|
||||
|
||||
const std = @import("std");
|
||||
const toml = @import("toml");
|
||||
|
||||
pub fn parse(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !toml.Parsed(Config)
|
||||
{
|
||||
var parser = toml.Parser(Config).init(allocator);
|
||||
defer parser.deinit();
|
||||
|
||||
return parser.parseFile(io,path);
|
||||
}
|
||||
|
||||
test "parse zocket config"
|
||||
{
|
||||
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const parsed = try parse(io, allocator, "config/config.toml");
|
||||
defer parsed.deinit();
|
||||
|
||||
const cfg = parsed.value;
|
||||
try std.testing.expectEqualStrings("node-01", cfg.node.id);
|
||||
try std.testing.expect(cfg.listen.client.enabled);
|
||||
try std.testing.expectEqual(@as(usize, 1), cfg.mesh.peers.len);
|
||||
try std.testing.expectEqualStrings("node-02", cfg.mesh.peers[0].id);
|
||||
}
|
||||
209
src/config/models.zig
Normal file
209
src/config/models.zig
Normal file
@@ -0,0 +1,209 @@
|
||||
const std = @import("std");
|
||||
const toml = @import("toml");
|
||||
|
||||
//
|
||||
|
||||
pub const Config = struct {
|
||||
node: Node,
|
||||
log: Log = .{},
|
||||
listen: Listen = .{},
|
||||
mesh: Mesh = .{},
|
||||
metrics: Metrics = .{},
|
||||
};
|
||||
|
||||
//
|
||||
|
||||
/// Per-instance identity. Intentionally has no defaults - id/data_dir/private_key
|
||||
/// are meaningless without a concrete deployment behind them.
|
||||
pub const Node = struct {
|
||||
id: []const u8,
|
||||
data_dir: []const u8,
|
||||
private_key: []const u8, // ed25519; auto-generated on first run if missing
|
||||
};
|
||||
|
||||
pub const LogLevel = enum {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
@"error",
|
||||
};
|
||||
|
||||
pub const LogFormat = enum {
|
||||
json,
|
||||
text,
|
||||
};
|
||||
|
||||
pub const Log = struct {
|
||||
level: LogLevel = .info,
|
||||
format: LogFormat = .json,
|
||||
output: []const u8 = "stdout", // or "/path/to/file.log"
|
||||
};
|
||||
|
||||
pub const Listen = struct {
|
||||
client: ListenClient = .{},
|
||||
peer: ListenPeer = .{},
|
||||
};
|
||||
|
||||
pub const ListenClient = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "0.0.0.0:8080",
|
||||
tls: ClientTls = .{},
|
||||
auth: ClientAuth = .{},
|
||||
access_control: AccessControl = .{},
|
||||
limits: ClientLimits = .{},
|
||||
};
|
||||
|
||||
pub const ClientTls = struct {
|
||||
enabled: bool = false,
|
||||
cert: []const u8 = "/etc/zocket/client.crt",
|
||||
key: []const u8 = "/etc/zocket/client.key",
|
||||
};
|
||||
|
||||
pub const ClientAuthMode = enum {
|
||||
none, // clients self-declare their client_id
|
||||
token, // client_id is derived from the matched identity below
|
||||
};
|
||||
|
||||
pub const ClientAuth = struct {
|
||||
mode: ClientAuthMode = .token,
|
||||
identities: []Identity = &[_]Identity{},
|
||||
};
|
||||
|
||||
/// A single client credential. No defaults as each identity is a distinct
|
||||
/// (id, token) pair that must be explicitly provisioned.
|
||||
pub const Identity = struct {
|
||||
id: []const u8,
|
||||
token: []const u8,
|
||||
};
|
||||
|
||||
/// Shared by listen.client.access_control and listen.peer.access_control.
|
||||
pub const AccessControlMode = enum {
|
||||
allowlist,
|
||||
blocklist,
|
||||
disabled,
|
||||
};
|
||||
|
||||
pub const AccessControl = struct {
|
||||
mode: AccessControlMode = .allowlist,
|
||||
list: [][]const u8 = &[_][]const u8{}, // IP | CIDR entries
|
||||
};
|
||||
|
||||
pub const ClientLimits = struct {
|
||||
max_message_bytes: i64 = 65536,
|
||||
max_frame_bytes: i64 = 65536,
|
||||
idle_timeout_secs: i64 = 300,
|
||||
ping_interval_secs: i64 = 30,
|
||||
max_missed_pongs: i64 = 2,
|
||||
max_connections: i64 = 256,
|
||||
rate_limit: RateLimit = .{},
|
||||
};
|
||||
|
||||
pub const RateLimit = struct {
|
||||
enabled: bool = true,
|
||||
messages_per_sec: i64 = 50,
|
||||
burst: i64 = 100,
|
||||
};
|
||||
|
||||
pub const ListenPeer = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "0.0.0.0:8181",
|
||||
tls: PeerTls = .{},
|
||||
auth: PeerAuth = .{},
|
||||
access_control: AccessControl = .{},
|
||||
limits: PeerLimits = .{},
|
||||
};
|
||||
|
||||
pub const PeerTls = struct {
|
||||
enabled: bool = false,
|
||||
cert: []const u8 = "/etc/zocket/peer.crt",
|
||||
key: []const u8 = "/etc/zocket/peer.key",
|
||||
require_client_cert: bool = false,
|
||||
};
|
||||
|
||||
pub const PeerAuthMode = enum {
|
||||
pubkey, // only supported mode: signed-nonce challenge, never a shared secret
|
||||
};
|
||||
|
||||
pub const PeerAuth = struct {
|
||||
mode: PeerAuthMode = .pubkey,
|
||||
};
|
||||
|
||||
pub const PeerLimits = struct {
|
||||
max_message_bytes: i64 = 1048576,
|
||||
idle_timeout_secs: i64 = 60,
|
||||
ping_interval_secs: i64 = 15,
|
||||
};
|
||||
|
||||
pub const MeshTopology = enum {
|
||||
full,
|
||||
@"static-partial",
|
||||
disabled,
|
||||
};
|
||||
|
||||
pub const RoutingMode = enum {
|
||||
gossip,
|
||||
reactive,
|
||||
static,
|
||||
};
|
||||
|
||||
pub const Mesh = struct {
|
||||
enabled: bool = true,
|
||||
topology: MeshTopology = .full,
|
||||
routing_mode: RoutingMode = .gossip,
|
||||
discoverability: Discoverability = .{},
|
||||
peers: []MeshPeer = &[_]MeshPeer{},
|
||||
trust: Trust = .{},
|
||||
gossip: Gossip = .{},
|
||||
broadcast: Broadcast = .{},
|
||||
reactive: Reactive = .{},
|
||||
};
|
||||
|
||||
pub const DiscoverabilityMode = enum {
|
||||
// mesh.peers is the complete, manually-maintained set of instances.
|
||||
static,
|
||||
// mesh.peers is just the initial seed; unknown nodes may connect and
|
||||
// prove possession of a pubkey, then get auto-trusted and gossiped
|
||||
// mesh-wide. listen.peer.access_control is the real gate in this mode.
|
||||
broadcast,
|
||||
};
|
||||
|
||||
pub const Discoverability = struct {
|
||||
mode: DiscoverabilityMode = .static,
|
||||
};
|
||||
|
||||
/// A statically-pinned peer. No defaults for id/addr/pubkey as pinning a
|
||||
/// peer's identity is the entire point, so these must be explicit.
|
||||
pub const MeshPeer = struct {
|
||||
id: []const u8,
|
||||
addr: []const u8,
|
||||
pubkey: []const u8,
|
||||
reconnect: bool = true,
|
||||
backoff_min_ms: i64 = 500,
|
||||
backoff_max_ms: i64 = 30000,
|
||||
};
|
||||
|
||||
pub const Trust = struct {
|
||||
gossip_new_peers: bool = true, // propagate newly-enrolled peer identities mesh-wide
|
||||
};
|
||||
|
||||
pub const Gossip = struct {
|
||||
seen_cache_ttl_secs: i64 = 300,
|
||||
route_ttl_secs: i64 = 0, // 0 = no expiry, rely on explicit ROUTE_REMOVE
|
||||
};
|
||||
|
||||
pub const Broadcast = struct {
|
||||
// Peer-only (target = "*"); never exposed to clients on listen.client.
|
||||
enabled: bool = true,
|
||||
};
|
||||
|
||||
pub const Reactive = struct {
|
||||
// Only used when mesh.routing_mode = .reactive.
|
||||
query_ttl_hops: i64 = 4,
|
||||
query_timeout_ms: i64 = 2000,
|
||||
negative_cache_secs: i64 = 30,
|
||||
};
|
||||
|
||||
pub const Metrics = struct {
|
||||
enabled: bool = true,
|
||||
bind: []const u8 = "127.0.0.1:9090", // prometheus format
|
||||
};
|
||||
149
src/logging/log.zig
Normal file
149
src/logging/log.zig
Normal file
@@ -0,0 +1,149 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Severity levels for log messages, ordered from least to most severe.
|
||||
///
|
||||
/// A `Logger`'s configured `level` acts as a filter: messages logged
|
||||
/// at a lower severity than the logger's level are silently dropped.
|
||||
pub const Level = enum {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
@"error",
|
||||
};
|
||||
|
||||
/// Configuration passed to `Logger.init`.
|
||||
///
|
||||
/// `level` sets the minimum severity that will be written; messages
|
||||
/// below this level are discarded.
|
||||
///
|
||||
/// `buffer` is the size, in bytes, of the internal write buffer
|
||||
/// allocated for `writer`. Larger buffers reduce the number of
|
||||
/// underlying writes at the cost of more memory and higher latency
|
||||
/// before data is flushed.
|
||||
///
|
||||
/// `writer` is the underlying file the logger writes to (e.g. stderr
|
||||
/// or a log file). The logger takes no ownership of it beyond the
|
||||
/// lifetime of the wrapping `std.Io.File.Writer`.
|
||||
pub const InitOptions = struct {
|
||||
level: Level = .info,
|
||||
buffer: usize = 1024,
|
||||
file: std.Io.File,
|
||||
io: std.Io,
|
||||
};
|
||||
|
||||
/// A minimal, thread-safe, buffered logger.
|
||||
///
|
||||
/// Writes are buffered and only flushed automatically on `warn`,
|
||||
/// `@"error"`, or `deinit`.
|
||||
/// `debug` and `info` messages may sit in the buffer until it fills,
|
||||
/// a higher-severity message is logged, `flush` or `deinit` is called.
|
||||
///
|
||||
/// Must be initialized with `init` before use and cleaned up with
|
||||
/// `deinit`. Not copyable once initialized, as `mutex` and `writer`
|
||||
/// hold state tied to the original instance.
|
||||
pub const Logger = struct {
|
||||
level: Level = .info,
|
||||
buffer: []u8 = undefined,
|
||||
writer: std.Io.File.Writer = undefined,
|
||||
mutex: std.Io.Mutex = undefined,
|
||||
alloc: std.mem.Allocator = undefined,
|
||||
io: std.Io = undefined,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Creates and returns an initialized `Logger`.
|
||||
///
|
||||
/// Allocates `options.buffer` bytes from `allocator` for internal
|
||||
/// write buffering; ownership of this allocation belongs to the
|
||||
/// returned `Logger` and is freed in `deinit`.
|
||||
///
|
||||
/// `allocator` is stored on the result and reused for cleanup, so
|
||||
/// it must remain valid for the logger's lifetime.
|
||||
///
|
||||
/// Safe to move or copy the returned value freely before first
|
||||
/// use, since the write buffer lives on the heap rather than
|
||||
/// inside the `Logger` struct itself. Once a method has been
|
||||
/// called on it, the logger must stay at a fixed address (e.g.
|
||||
/// behind a pointer or in a `var` that isn't reassigned by value)
|
||||
/// for the remainder of its life.
|
||||
///
|
||||
/// Returns an error if the buffer allocation fails.
|
||||
pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self
|
||||
{
|
||||
const buffer = try allocator.alloc(u8, options.buffer);
|
||||
|
||||
return Self{
|
||||
.level = options.level,
|
||||
.buffer = buffer,
|
||||
.writer = options.file.writer(options.io, buffer),
|
||||
.mutex = .init,
|
||||
.alloc = allocator,
|
||||
.io = options.io,
|
||||
};
|
||||
}
|
||||
|
||||
/// Flushes any buffered output and releases the logger's buffer.
|
||||
///
|
||||
/// Safe to call once initialization via `init` has succeeded.
|
||||
/// Flush errors are silently ignored, since there is no caller
|
||||
/// left to meaningfully report them to at teardown time.
|
||||
///
|
||||
/// Must not be called more than once, as the buffer is freed
|
||||
/// unconditionally.
|
||||
pub fn deinit(logger: *Self) void
|
||||
{
|
||||
logger.mutex.lock(logger.io) catch {};
|
||||
logger.writer.interface.flush() catch {};
|
||||
logger.mutex.unlock(logger.io);
|
||||
|
||||
logger.alloc.free(logger.buffer);
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// Formats and writes a log message at the given `level`.
|
||||
///
|
||||
/// Messages below `logger.level` are dropped before anything is
|
||||
/// written or locked. `warn` and `@"error"` messages are flushed
|
||||
/// immediately so they reach the destination even if the process
|
||||
/// crashes shortly after; `debug` and `info` messages are left
|
||||
/// buffered for efficiency.
|
||||
///
|
||||
/// No buffer size/overflow checks as the writer automatically
|
||||
/// drains on overflow.
|
||||
///
|
||||
/// Returns an error if formatting or writing to the underlying
|
||||
/// writer fails.
|
||||
fn log(logger: *Self, comptime fmt: []const u8, args: anytype, level: Level) !void
|
||||
{
|
||||
if (@intFromEnum(level) < @intFromEnum(logger.level)) return;
|
||||
|
||||
try logger.mutex.lock(logger.io);
|
||||
defer logger.mutex.unlock(logger.io);
|
||||
|
||||
try logger.writer.interface.print(fmt, args);
|
||||
|
||||
if (level == .warn or level == .@"error") {
|
||||
try logger.writer.interface.flush();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
pub fn debug(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch {}; }
|
||||
pub fn info(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch {}; }
|
||||
pub fn warn(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch {}; }
|
||||
pub fn @"error"(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") catch {}; }
|
||||
|
||||
/// Forces any buffered output to be written to the underlying
|
||||
/// writer immediately.
|
||||
///
|
||||
/// Returns an error if the underlying writer fails to flush.
|
||||
pub fn flush(logger: *Self) !void
|
||||
{
|
||||
try logger.mutex.lock(logger.io);
|
||||
defer logger.mutex.unlock(logger.io);
|
||||
|
||||
try logger.writer.interface.flush();
|
||||
}
|
||||
};
|
||||
93
src/main.zig
Normal file
93
src/main.zig
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Entry point for the zocket daemon.
|
||||
//!
|
||||
//! Handles allocator selection (debug-checked vs. release), CLI argument
|
||||
//! parsing, and configuration path resolution before handing off to `run`.
|
||||
|
||||
const zocket = @import("zocket");
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const options = @import("build_options");
|
||||
|
||||
|
||||
const args = zocket.deps.args;
|
||||
|
||||
/// Resolves the config path, then delegates to `run`.
|
||||
///
|
||||
/// Allocator strategy:
|
||||
/// - Debug builds use `DebugAllocator`, which tracks every allocation and
|
||||
/// panics/exits on leaks or double-frees.
|
||||
/// - Release builds use `smp_allocator`.
|
||||
pub fn main(init: std.process.Init) !void
|
||||
{
|
||||
const debug = builtin.mode == .Debug;
|
||||
const io = init.io;
|
||||
|
||||
var gpa: std.heap.DebugAllocator(.{}) = .init;
|
||||
defer if (gpa.deinit() == .leak) std.process.exit(1);
|
||||
|
||||
const allocator = if (debug) gpa.allocator() else std.heap.smp_allocator;
|
||||
|
||||
var tmp_arena: ?std.heap.ArenaAllocator = .init(allocator);
|
||||
errdefer _ = zocket.memory.deinitIfExists(&tmp_arena);
|
||||
|
||||
const tmp_allocator = tmp_arena.?.allocator();
|
||||
|
||||
var parser = try args.ArgumentParser.init(tmp_allocator, .{
|
||||
.name = "zocket",
|
||||
.version = options.version,
|
||||
.description = "A lightweight Zig WebSocket server and mesh routing daemon.",
|
||||
});
|
||||
|
||||
try parser.addOption("path", .{
|
||||
.short = 'p',
|
||||
.help = "Configuration-File Path (*.toml)",
|
||||
});
|
||||
|
||||
var result = try parser.parseProcess(init);
|
||||
|
||||
const path = if (debug) path:
|
||||
{
|
||||
const tmp_path = result.getString("path") orelse "config/config.toml";
|
||||
break :path try allocator.dupe(u8, tmp_path);
|
||||
}
|
||||
else path:
|
||||
{
|
||||
const tmp_path = result.getString("path") orelse blk: {
|
||||
const exe = try std.process.executableDirPathAlloc(io, tmp_allocator);
|
||||
break :blk try std.Io.Dir.path.join(tmp_allocator, &.{ exe, "config/config.toml" });
|
||||
};
|
||||
break :path try allocator.dupe(u8, tmp_path);
|
||||
};
|
||||
|
||||
_ = zocket.memory.deinitIfExists(&tmp_arena);
|
||||
|
||||
try run(io, allocator, path);
|
||||
}
|
||||
|
||||
/// Loads and parses the configuration file at `path`, then hands off to
|
||||
/// the rest of the application.
|
||||
///
|
||||
/// Owns `path` and frees it once it's no longer needed; Callers must not free `path` themselves!
|
||||
///
|
||||
/// On parse failure, logs a human-readable reason and terminates the
|
||||
/// process immediately via `std.process.exit`.
|
||||
fn run(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !void
|
||||
{
|
||||
const parsed = zocket.config.parse(io, allocator, path) catch |err|
|
||||
{
|
||||
const msg: []const u8 = switch (err) {
|
||||
error.FileNotFound => "File not found",
|
||||
error.AccessDenied => "Permission denied reading",
|
||||
else => "Unexpected error opening",
|
||||
};
|
||||
std.log.err("{s}: '{s}' ({any})", .{ msg, path, err });
|
||||
std.process.exit(1);
|
||||
};
|
||||
defer parsed.deinit();
|
||||
allocator.free(path);
|
||||
|
||||
const configuration = parsed.value;
|
||||
|
||||
std.debug.print("{any}", .{configuration});
|
||||
}
|
||||
103
src/memory/util.zig
Normal file
103
src/memory/util.zig
Normal file
@@ -0,0 +1,103 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Deinitializes `resource` (allocator owner) only if it is still live.
|
||||
///
|
||||
/// Pass a pointer to the allocator owner, such as an `ArenaAllocator`.
|
||||
///
|
||||
/// If `live` is `true`, calls `resource.deinit()` and then sets
|
||||
/// `live` to `false`, preventing a later cleanup path from attempting
|
||||
/// to deinitialize the same resource again.
|
||||
///
|
||||
/// The return value of `resource.deinit()` is returned from the function.
|
||||
pub fn deinitIfLive(resource: anytype, live: *bool) ?LiveDeinitReturn(@TypeOf(resource))
|
||||
{
|
||||
if (!live.*) return null;
|
||||
|
||||
live.* = false;
|
||||
return resource.deinit();
|
||||
}
|
||||
|
||||
/// Deinitializes the resources stored in `resource`
|
||||
/// (allocator owner) only if still exists.
|
||||
///
|
||||
/// Pass a pointer to an optional allocator owner, such as
|
||||
/// `*?std.heap.ArenaAllocator`.
|
||||
///
|
||||
/// If `resource.*` is non-null, calls `deinit()` on the payload
|
||||
/// and then sets `resource.* = null`, preventing a later cleanup
|
||||
/// path from attempting to deinitialize the same resource again.
|
||||
///
|
||||
/// The return value of `resource.*.?.deinit()` is returned
|
||||
/// from the function.
|
||||
pub fn deinitIfExists(resource: anytype) ?ExistsDeinitReturn(@TypeOf(resource))
|
||||
{
|
||||
if (resource.*) |*payload|
|
||||
{
|
||||
const result = payload.deinit();
|
||||
resource.* = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
fn Payload(comptime Pointer: type) type
|
||||
{
|
||||
const Child = ValidatedChild(Pointer);
|
||||
|
||||
if (@typeInfo(Child) != .optional)
|
||||
@compileError("Expected a pointer to an optional resource (*?T)!");
|
||||
|
||||
return @typeInfo(Child).optional.child;
|
||||
}
|
||||
|
||||
fn ValidatedChild(comptime Pointer: type) type
|
||||
{
|
||||
if (@typeInfo(Pointer) != .pointer)
|
||||
@compileError("Expected a pointer!");
|
||||
|
||||
if (@typeInfo(Pointer).pointer.size != .one)
|
||||
@compileError("Expected a single-item pointer!");
|
||||
|
||||
return @typeInfo(Pointer).pointer.child;
|
||||
}
|
||||
|
||||
fn LiveDeinitReturn(comptime Pointer: type) type {
|
||||
const Child = ValidatedChild(Pointer);
|
||||
return @typeInfo(@TypeOf(Child.deinit)).@"fn".return_type.?;
|
||||
}
|
||||
|
||||
fn ExistsDeinitReturn(comptime Pointer: type) type {
|
||||
return @typeInfo(@TypeOf(Payload(Pointer).deinit)).@"fn".return_type.?;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
test "deinitIfLive deinitializes an ArenaAllocator once" {
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
var live = true;
|
||||
|
||||
const alloc = arena.allocator();
|
||||
_ = try alloc.alloc(u8, 1);
|
||||
|
||||
_ = deinitIfLive(&arena, &live);
|
||||
try std.testing.expect(!live);
|
||||
|
||||
// Must not deinitialize it again.
|
||||
try std.testing.expect(deinitIfLive(&arena, &live) == null);
|
||||
}
|
||||
|
||||
test "deinitIfExists deinitializes an optional ArenaAllocator once" {
|
||||
var arena: ?std.heap.ArenaAllocator = .init(std.testing.allocator);
|
||||
|
||||
const alloc = arena.?.allocator();
|
||||
_ = try alloc.alloc(u8, 1);
|
||||
|
||||
_ = deinitIfExists(&arena);
|
||||
try std.testing.expect(arena == null);
|
||||
|
||||
// Must not deinitialize it again.
|
||||
try std.testing.expect(deinitIfExists(&arena) == null);
|
||||
}
|
||||
15
src/root.zig
Normal file
15
src/root.zig
Normal file
@@ -0,0 +1,15 @@
|
||||
pub const deps = struct {
|
||||
pub const args = @import("args");
|
||||
};
|
||||
|
||||
|
||||
pub const config = @import("config/config.zig");
|
||||
pub const memory = @import("memory/util.zig");
|
||||
pub const signals = @import("signals/models.zig");
|
||||
pub const logging = @import("logging/log.zig");
|
||||
|
||||
test {
|
||||
_ = config;
|
||||
_ = memory;
|
||||
_ = signals;
|
||||
}
|
||||
53
src/signals/models.zig
Normal file
53
src/signals/models.zig
Normal file
@@ -0,0 +1,53 @@
|
||||
const std = @import("std");
|
||||
const posix = std.posix;
|
||||
const linux = std.os.linux;
|
||||
|
||||
pub const Event = enum {
|
||||
none, // Sentinel
|
||||
shutdown, // SIGTERM, SIGINT
|
||||
reload, // SIGHUP
|
||||
|
||||
pub fn fromSignal(sig: i32) ?Event
|
||||
{
|
||||
const signal: posix.SIG = @enumFromInt(@as(u32, @intCast(sig)));
|
||||
|
||||
return switch (signal) {
|
||||
.TERM,
|
||||
.INT => .shutdown,
|
||||
.HUP => .reload,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const SignalFd = struct {
|
||||
fd: posix.fd_t,
|
||||
|
||||
pub fn init() !SignalFd
|
||||
{
|
||||
var mask = linux.sigemptyset();
|
||||
|
||||
linux.sigaddset(&mask, posix.SIG.TERM);
|
||||
linux.sigaddset(&mask, posix.SIG.INT);
|
||||
linux.sigaddset(&mask, posix.SIG.HUP);
|
||||
|
||||
posix.sigprocmask(posix.SIG.BLOCK, &mask, null);
|
||||
|
||||
const fd = try posix.signalfd(-1, &mask, linux.SFD.CLOEXEC);
|
||||
|
||||
return .{ .fd = fd };
|
||||
}
|
||||
|
||||
pub fn deinit(self: SignalFd) void { _ = std.os.linux.close(self.fd); }
|
||||
|
||||
//
|
||||
|
||||
pub fn read(self: SignalFd) !Event
|
||||
{
|
||||
var info: linux.signalfd_siginfo = undefined;
|
||||
|
||||
_ = try posix.read(self.fd, std.mem.asBytes(&info));
|
||||
|
||||
return Event.fromSignal(@intCast(info.signo)) orelse .none;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user