nd/conf: add lnd config (de)serializer and use it conf gen
ci/woodpecker/push/woodpecker Pipeline was successful Details

the change is based on the previously added ini parser.
this makes lnd config gen more robust and allows to presist config
modifications in the future, such as changing node alias.
master
alex 11 months ago
parent 55531668eb
commit 0a74bf2c3a
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -21,6 +21,8 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
const libnif = libnif_dep.artifact("nif");
// ini file format parser
const libini = b.addModule("ini", .{ .source_file = .{ .path = "lib/ini/src/ini.zig" } });
const common_cflags = .{
"-Wall",
@ -119,6 +121,7 @@ pub fn build(b: *std.Build) void {
});
tests.addOptions("build_options", buildopts);
tests.addModule("nif", libnif_dep.module("nif"));
tests.addModule("ini", libini);
tests.linkLibrary(libnif);
const run_tests = b.addRunArtifact(tests);

@ -0,0 +1,7 @@
pub const LndConf = @import("lightning/LndConf.zig");
pub const lndhttp = @import("lightning/lndhttp.zig");
test {
const std = @import("std");
std.testing.refAllDecls(@This());
}

@ -0,0 +1,288 @@
//! lnd config parser and serializer based on github.com/jessevdk/go-flags,
//! specifically https://pkg.go.dev/github.com/jessevdk/go-flags#IniParser.Parse
//!
//! see https://docs.lightning.engineering/lightning-network-tools/lnd/lnd.conf
//! for lnd config docs.
const std = @import("std");
const ini = @import("ini");
const logger = std.log.scoped(.lndconf);
sections: std.ArrayList(Section),
// holds section allocations and their key/value pairs.
// initialized at `init` and dropped at `deinit`.
arena: *std.heap.ArenaAllocator,
// case-insensitive default group name according to source doc comments
// at github.com/jessevdk/go-flags
pub const MainSection = "application options";
// a section key/value pairs (properties) followed by its declaration
// in square brackets like "[section name]".
pub const Section = struct {
name: []const u8, // section name without square brackets
props: std.StringArrayHashMap(PropValue),
/// holds all allocations throughout the lifetime of the section.
/// initialized in `appendSection`.
alloc: std.mem.Allocator,
/// key and value are dup'ed by `Section.alloc`.
/// if an existing property exist, it necessarily becomes an array and the
/// new value is appended to the array.
pub fn appendPropStr(self: *Section, key: []const u8, value: []const u8) !void {
const vdup = try self.alloc.dupe(u8, value);
errdefer self.alloc.free(vdup);
var res = try self.props.getOrPut(try self.alloc.dupe(u8, key));
if (!res.found_existing) {
res.value_ptr.* = .{ .str = vdup };
return;
}
switch (res.value_ptr.*) {
.str => |s| {
var list = try std.ArrayList([]const u8).initCapacity(self.alloc, 2);
try list.append(s); // s is already owned by arena backing self.alloc
try list.append(vdup);
res.value_ptr.* = .{ .astr = try list.toOwnedSlice() };
},
.astr => |a| {
var list = std.ArrayList([]const u8).fromOwnedSlice(self.alloc, a);
try list.append(vdup);
res.value_ptr.* = .{ .astr = try list.toOwnedSlice() };
},
}
}
/// replaces any existing property by the given key with the new value.
/// value is duplicated in the owned memory.
pub fn setPropStr(self: *Section, key: []const u8, value: []const u8) !void {
const vdup = try self.alloc.dupe(u8, value);
errdefer self.alloc.free(vdup);
var res = try self.props.getOrPut(try self.alloc.dupe(u8, key));
if (res.found_existing) {
res.value_ptr.free(self.alloc);
}
res.value_ptr.* = .{ .str = vdup };
}
/// formats value using `std.fmt.format` and calls `setPropStr`.
/// the resulting formatted value cannot exceed 512 characters.
pub fn setPropFmt(self: *Section, key: []const u8, comptime fmt: []const u8, args: anytype) !void {
var buf = [_]u8{0} ** 512;
const val = try std.fmt.bufPrint(&buf, fmt, args);
return self.setPropStr(key, val);
}
};
/// the value part of a key/value pair.
pub const PropValue = union(enum) {
str: []const u8, // a "string"
astr: [][]const u8, // an array of strings, all values of repeated keys
/// used internally when replacing an existing value in functions such as
/// `Section.setPropStr`
fn free(self: PropValue, allocator: std.mem.Allocator) void {
switch (self) {
.str => |s| allocator.free(s),
.astr => |a| {
for (a) |s| allocator.free(s);
allocator.free(a);
},
}
}
};
const LndConf = @This();
/// creates a empty config, ready to be populated start with `appendSection`.
/// the object is serialized with `dumpWriter`. `deinit` when no long used.
/// to parse an existing config use `load` or `loadReader`.
pub fn init(allocator: std.mem.Allocator) !LndConf {
var arena = try allocator.create(std.heap.ArenaAllocator);
arena.* = std.heap.ArenaAllocator.init(allocator);
return .{
.arena = arena,
.sections = std.ArrayList(Section).init(arena.allocator()),
};
}
// frees all resources used by the config object.
pub fn deinit(self: LndConf) void {
const allocator = self.arena.child_allocator;
self.arena.deinit();
allocator.destroy(self.arena);
}
/// parses an existing config at the specified file path.
/// a thin wrapper around `loadReader` passing it a file reader.
pub fn load(allocator: std.mem.Allocator, filepath: []const u8) !LndConf {
const f = try std.fs.cwd().openFile(filepath, .{ .mode = .read_only });
defer f.close();
return loadReader(allocator, f.reader());
}
/// parses config contents from reader `r`.
/// makes no section deduplication: all sections are simply appended to `sections`
/// in the encountered order, with ascii characters of the name converted to lower case.
/// values of identical key names are grouped into `PropValue.astr`.
pub fn loadReader(allocator: std.mem.Allocator, r: anytype) !LndConf {
var parser = ini.parse(allocator, r);
defer parser.deinit();
var conf = try LndConf.init(allocator);
errdefer conf.deinit();
var currsect: ?*Section = null;
while (try parser.next()) |record| {
switch (record) {
.section => |name| currsect = try conf.appendSection(name),
.property => |kv| {
if (currsect == null) {
currsect = try conf.appendSection(MainSection);
}
try currsect.?.appendPropStr(kv.key, kv.value);
},
.enumeration => |v| logger.warn("ignoring key without value: {s}", .{v}),
}
}
return conf;
}
/// serializes the config into writer `w`.
pub fn dumpWriter(self: LndConf, w: anytype) !void {
for (self.sections.items, 0..) |*sec, i| {
if (i > 0) {
try w.writeByte('\n');
}
try w.print("[{s}]\n", .{sec.name});
var it = sec.props.iterator();
while (it.next()) |kv| {
const key = kv.key_ptr.*;
switch (kv.value_ptr.*) {
.str => |s| try w.print("{s}={s}\n", .{ key, s }),
.astr => |a| for (a) |s| try w.print("{s}={s}\n", .{ key, s }),
}
}
}
}
/// makes no deduplication: callers must do it themselves.
pub fn appendDefaultSection(self: *LndConf) !*Section {
return self.appendSection(MainSection);
}
/// creates a new confg section, owning a name copy dup'ed using the `arena` allocator.
/// the section name ascii is converted to lower case.
pub fn appendSection(self: *LndConf, name: []const u8) !*Section {
const alloc = self.arena.allocator();
var name_dup = try alloc.dupe(u8, name);
toLower(name_dup);
try self.sections.append(.{
.name = name_dup,
.props = std.StringArrayHashMap(PropValue).init(alloc),
.alloc = alloc,
});
return &self.sections.items[self.sections.items.len - 1];
}
/// returns a section named `MainSection`, if any.
pub fn mainSection(self: LndConf) ?*Section {
return self.findSection(MainSection);
}
/// O(n); name must be in lower case.
pub fn findSection(self: *const LndConf, name: []const u8) ?*Section {
for (self.sections.items) |*sec| {
if (std.mem.eql(u8, sec.name, name)) {
return sec;
}
}
return null;
}
fn toLower(s: []u8) void {
for (s, 0..) |c, i| {
switch (c) {
'A'...'Z' => s[i] = c | 0b00100000,
else => {},
}
}
}
test "lnd: conf load dump" {
const t = std.testing;
const tt = @import("../test.zig");
var tmp = try tt.TempDir.create();
defer tmp.cleanup();
try tmp.dir.writeFile("conf.ini",
\\; top comment
\\[application options]
\\foo = bar
\\; line comment
\\baz = quix1 ; inline comment
\\baz = quix2
\\
\\[AutopiloT]
\\autopilot.active=false
);
const clean_conf =
\\[application options]
\\foo=bar
\\baz=quix1
\\baz=quix2
\\
\\[autopilot]
\\autopilot.active=false
\\
;
const conf = try LndConf.load(t.allocator, try tmp.join(&.{"conf.ini"}));
defer conf.deinit();
var dump = std.ArrayList(u8).init(t.allocator);
defer dump.deinit();
try conf.dumpWriter(dump.writer());
try t.expectEqualStrings(clean_conf, dump.items);
const sec = conf.mainSection().?;
try t.expectEqualStrings("bar", sec.props.get("foo").?.str);
const bazval: []const []const u8 = &.{ "quix1", "quix2" };
try tt.expectDeepEqual(bazval, sec.props.get("baz").?.astr);
}
test "lnd: conf append and dump" {
const t = std.testing;
var conf = try LndConf.init(t.allocator);
defer conf.deinit();
var sec = try conf.appendSection(MainSection);
try sec.appendPropStr("foo", "bar");
var buf = std.ArrayList(u8).init(t.allocator);
defer buf.deinit();
try conf.dumpWriter(buf.writer());
try t.expectEqualStrings("[application options]\nfoo=bar\n", buf.items);
try sec.setPropStr("foo", "baz");
var sec2 = try conf.appendSection("test");
try sec2.setPropStr("bar", "quix1");
try sec2.appendPropStr("bar", "quix2");
buf.clearAndFree();
try conf.dumpWriter(buf.writer());
const want_conf =
\\[application options]
\\foo=baz
\\
\\[test]
\\bar=quix1
\\bar=quix2
\\
;
try t.expectEqualStrings(want_conf, buf.items);
}

@ -3,7 +3,7 @@
const std = @import("std");
const base64enc = std.base64.standard.Encoder;
const types = @import("types.zig");
const types = @import("../types.zig");
/// safe for concurrent use as long as Client.allocator is.
pub const Client = struct {

@ -2,6 +2,8 @@
//! the structure is defined in `Data`.
const std = @import("std");
const lightning = @import("../lightning.zig");
const types = @import("../types.zig");
const logger = std.log.scoped(.config);
@ -270,7 +272,7 @@ pub fn lndConnectWaitMacaroonFile(self: Config, allocator: std.mem.Allocator, ty
/// returns the bytes printed to outbuf.
pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize) ![]const u8 {
const filepath = LND_WALLETUNLOCK_PATH;
const lnduser = try std.process.getUserInfo(LND_OS_USER);
const lnduser = try types.getUserInfo(LND_OS_USER);
const allocator = self.arena.child_allocator;
const opt = .{ .mode = 0o400 };
@ -290,64 +292,70 @@ pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize
return hex;
}
/// creates or overwrites existing lnd config file at `LND_CONF_PATH`.
pub fn genLndConfig(self: Config, opt: struct { autounlock: bool }) !void {
const confpath = LND_CONF_PATH;
const lnduser = try std.process.getUserInfo(LND_OS_USER);
/// options for genLndConfig.
pub const LndConfOpt = struct {
autounlock: bool,
path: ?[]const u8 = null, // defaults to LND_CONF_PATH
};
/// creates or overwrites existing lnd config file on disk.
pub fn genLndConfig(self: Config, opt: LndConfOpt) !void {
const confpath = opt.path orelse LND_CONF_PATH;
const lnduser = try types.getUserInfo(LND_OS_USER);
const allocator = self.arena.child_allocator;
const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), confpath, .{ .mode = 0o400 });
defer file.destroy(); // frees resources; does NOT delete the file
const w = file.writer();
// main app settings
try w.writeAll("[Application Options]\n");
try w.writeAll("debuglevel=info\n");
try w.writeAll("maxpendingchannels=10\n");
try w.writeAll("maxlogfiles=3\n");
try w.writeAll("listen=[::]:9735\n"); // or 0.0.0.0:9735
try w.writeAll("rpclisten=0.0.0.0:10009\n");
try w.writeAll("restlisten=0.0.0.0:10010\n"); // TODO: replace with 127.0.0.1 and no-rest-tls=true?
try std.fmt.format(w, "alias={s}\n", .{"nakamochi"}); // TODO: make alias configurable
try std.fmt.format(w, "datadir={s}\n", .{LND_DATA_DIR});
try std.fmt.format(w, "logdir={s}\n", .{LND_LOG_DIR});
var conf = try lightning.LndConf.init(allocator);
defer conf.deinit();
var sec = try conf.appendDefaultSection();
try sec.setPropStr("debuglevel", "info");
try sec.setPropStr("maxpendingchannels", "10");
try sec.setPropStr("maxlogfiles", "3");
try sec.setPropStr("listen", "[::]:9735"); // or 0.0.0.0:9735
try sec.setPropStr("rpclisten", "0.0.0.0:10009");
try sec.setPropStr("restlisten", "0.0.0.0:10010");
try sec.setPropStr("alias", "nakamochi"); // TODO: make alias configurable
try sec.setPropStr("datadir", LND_DATA_DIR);
try sec.setPropStr("logdir", LND_LOG_DIR);
if (self.static.lnd_tor_hostname) |torhost| {
try std.fmt.format(w, "tlsextradomain={s}\n", .{torhost});
try std.fmt.format(w, "externalhosts={s}\n", .{torhost});
try sec.setPropStr("tlsextradomain", torhost);
try sec.setPropStr("externalhosts", torhost);
}
if (opt.autounlock) {
try std.fmt.format(w, "wallet-unlock-password-file={s}\n", .{LND_WALLETUNLOCK_PATH});
try sec.setPropStr("wallet-unlock-password-file", LND_WALLETUNLOCK_PATH);
}
// bitcoin chain settings
try w.writeAll("\n[bitcoin]\n");
try std.fmt.format(w, "bitcoin.chaindir={s}/chain/mainnet\n", .{LND_DATA_DIR});
try w.writeAll("bitcoin.active=true\n");
try w.writeAll("bitcoin.mainnet=True\n");
try w.writeAll("bitcoin.testnet=False\n");
try w.writeAll("bitcoin.regtest=False\n");
try w.writeAll("bitcoin.simnet=False\n");
try w.writeAll("bitcoin.node=bitcoind\n");
try w.writeAll("\n[bitcoind]\n");
try w.writeAll("bitcoind.zmqpubrawblock=tcp://127.0.0.1:8331\n");
try w.writeAll("bitcoind.zmqpubrawtx=tcp://127.0.0.1:8330\n");
try w.writeAll("bitcoind.rpchost=127.0.0.1\n");
try w.writeAll("bitcoind.rpcuser=rpc\n");
sec = try conf.appendSection("bitcoin");
try sec.setPropFmt("bitcoin.chaindir", "{s}/chain/mainnet", .{LND_DATA_DIR});
try sec.setPropStr("bitcoin.active", "true");
try sec.setPropStr("bitcoin.mainnet", "true");
try sec.setPropStr("bitcoin.testnet", "false");
try sec.setPropStr("bitcoin.regtest", "false");
try sec.setPropStr("bitcoin.simnet", "false");
try sec.setPropStr("bitcoin.node", "bitcoind");
sec = try conf.appendSection("bitcoind");
try sec.setPropStr("bitcoind.zmqpubrawblock", "tcp://127.0.0.1:8331");
try sec.setPropStr("bitcoind.zmqpubrawtx", "tcp://127.0.0.1:8330");
try sec.setPropStr("bitcoind.rpchost", "127.0.0.1");
try sec.setPropStr("bitcoind.rpcuser", "rpc");
if (self.static.bitcoind_rpc_pass) |rpcpass| {
try std.fmt.format(w, "bitcoind.rpcpass={s}\n", .{rpcpass});
try sec.setPropStr("bitcoind.rpcpass", rpcpass);
} else {
return error.GenLndConfigNoBitcoindRpcPass;
}
// other settings
try w.writeAll("\n[autopilot]\n");
try w.writeAll("autopilot.active=false\n");
try w.writeAll("\n[tor]\n");
try w.writeAll("tor.active=true\n");
try w.writeAll("tor.skip-proxy-for-clearnet-targets=true\n");
sec = try conf.appendSection("autopilot");
try sec.setPropStr("autopilot.active", "false");
sec = try conf.appendSection("tor");
try sec.setPropStr("tor.active", "true");
try sec.setPropStr("tor.skip-proxy-for-clearnet-targets", "true");
// persist the file in the correct location.
try file.finish();
// dump config into the file.
const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), confpath, .{ .mode = 0o400 });
defer file.destroy(); // frees resources; does NOT delete the file
try conf.dumpWriter(file.writer());
try file.finish(); // persist the file in the correct location
// change file ownership to that of the lnd system user.
const f = try std.fs.cwd().openFile(confpath, .{});
@ -355,7 +363,7 @@ pub fn genLndConfig(self: Config, opt: struct { autounlock: bool }) !void {
try f.chown(lnduser.uid, lnduser.gid);
}
test "init existing" {
test "ndconfig: init existing" {
const t = std.testing;
const tt = @import("../test.zig");
@ -375,7 +383,7 @@ test "init existing" {
try t.expectEqualStrings("/sysupdates/run.sh", conf.data.sysrunscript);
}
test "init null" {
test "ndconfig: init null" {
const t = std.testing;
const conf = try init(t.allocator, "/non/existent/config/file");
@ -385,7 +393,7 @@ test "init null" {
try t.expectEqualStrings(SYSUPDATES_RUN_SCRIPT_PATH, conf.data.sysrunscript);
}
test "dump" {
test "ndconfig: dump" {
const t = std.testing;
const tt = @import("../test.zig");
@ -416,7 +424,7 @@ test "dump" {
try t.expectEqualStrings("runscript.sh", parsed.value.sysrunscript);
}
test "switch sysupdates and infer" {
test "ndconfig: switch sysupdates and infer" {
const t = std.testing;
const tt = @import("../test.zig");
@ -448,11 +456,11 @@ test "switch sysupdates and infer" {
try t.expectEqual(SysupdatesChannel.dev, inferSysupdatesChannel(cronscript));
}
test "switch sysupdates with .run=true" {
test "ndconfig: switch sysupdates with .run=true" {
const t = std.testing;
const tt = @import("../test.zig");
// no arena deinit: expecting Config to
// no arena deinit here: expecting Config to auto-deinit.
var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator);
conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator);
var tmp = try tt.TempDir.create();
@ -492,3 +500,52 @@ fn testLoadConfigData(path: []const u8) !std.json.Parsed(Data) {
const jopt = .{ .ignore_unknown_fields = true, .allocate = .alloc_always };
return try std.json.parseFromSlice(Data, allocator, bytes, jopt);
}
test "ndconfig: genLndConfig" {
const t = std.testing;
const tt = @import("../test.zig");
// Config auto-deinits the arena.
var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator);
conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator);
var tmp = try tt.TempDir.create();
defer tmp.cleanup();
var conf = Config{
.arena = conf_arena,
.confpath = undefined, // unused
.data = .{
.syschannel = .master, // unused
.syscronscript = undefined, // unused
.sysrunscript = undefined, // unused
},
.static = .{
.lnd_tor_hostname = "test.onion",
.bitcoind_rpc_pass = "test secret",
},
};
defer conf.deinit();
const confpath = try tmp.join(&.{"lndconf.ini"});
try conf.genLndConfig(.{ .autounlock = false, .path = confpath });
const bytes = try std.fs.cwd().readFileAlloc(t.allocator, confpath, 1 << 20);
defer t.allocator.free(bytes);
try tt.expectSubstring("tlsextradomain=test.onion\n", bytes);
try tt.expectSubstring("externalhosts=test.onion\n", bytes);
try tt.expectNoSubstring("wallet-unlock-password-file", bytes);
try tt.expectSubstring("bitcoind.rpcpass=test secret\n", bytes);
try conf.genLndConfig(.{ .autounlock = true, .path = confpath });
const bytes2 = try std.fs.cwd().readFileAlloc(t.allocator, confpath, 1 << 20);
defer t.allocator.free(bytes2);
try tt.expectSubstring("wallet-unlock-password-file=", bytes2);
const lndconf = try lightning.LndConf.load(t.allocator, confpath);
defer lndconf.deinit();
try t.expect(lndconf.mainSection() != null);
try t.expect(lndconf.findSection("bitcoin") != null);
try t.expect(lndconf.findSection("bitcoind") != null);
try t.expect(lndconf.findSection("autopilot") != null);
try t.expect(lndconf.findSection("tor") != null);
}

@ -18,7 +18,7 @@ const time = std.time;
const bitcoindrpc = @import("../bitcoindrpc.zig");
const comm = @import("../comm.zig");
const Config = @import("Config.zig");
const lndhttp = @import("../lndhttp.zig");
const lndhttp = @import("../lightning.zig").lndhttp;
const network = @import("network.zig");
const screen = @import("../ui/screen.zig");
const SysService = @import("SysService.zig");

@ -315,11 +315,22 @@ pub fn expectDeepEqual(expected: anytype, actual: @TypeOf(expected)) !void {
}
}
pub fn expectSubstring(needle: []const u8, haystack: []const u8) !void {
const n = std.mem.count(u8, haystack, needle);
try std.testing.expect(n > 0);
}
pub fn expectNoSubstring(needle: []const u8, haystack: []const u8) !void {
const n = std.mem.count(u8, haystack, needle);
try std.testing.expect(n == 0);
}
test {
_ = @import("nd.zig");
_ = @import("nd/Daemon.zig");
_ = @import("nd/SysService.zig");
_ = @import("ngui.zig");
_ = @import("lightning.zig");
std.testing.refAllDecls(@This());
}

@ -9,11 +9,25 @@ pub usingnamespace if (builtin.is_test) struct {
pub const Timer = tt.TestTimer;
pub const ChildProcess = tt.TestChildProcess;
pub const WpaControl = tt.TestWpaControl;
/// always returns caller's (current process) user/group IDs.
/// atm works only on linux via getuid syscalls.
pub fn getUserInfo(name: []const u8) !std.process.UserInfo {
_ = name;
return .{
.uid = std.os.linux.getuid(),
.gid = std.os.linux.getgid(),
};
}
} else struct {
// regular types for production code.
pub const Timer = std.time.Timer;
pub const ChildProcess = std.ChildProcess;
pub const WpaControl = nif.wpa.Control;
pub fn getUserInfo(name: []const u8) !std.process.UserInfo {
return std.process.getUserInfo(name);
}
};
/// prefer this type over the std.ArrayList(u8) just to ensure consistency