From a080e1ac792a30dbac5ba1200f74bd04f295a432 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 7 Feb 2024 17:37:29 +0100 Subject: [PATCH] nd,ui: add a new facility to be able to change node name the "nodename" encompasses lnd alias and OS hostname. while the former may be seen by lightning node peers as a node name, the latter is how the device is seen on a local network such as WiFi. upon receiving a comm message set_nodename, nd sets both lightning node alias and hostname to that new name while applying restrictions such as RFC 1123 for hostnames. the lightning alias is written to lnd config file, regenerated and persistent, after which the lnd daemon is restarted to pick up the changes. network host name is changed by writing the name to /etc/hostname and issuing "hostname " shell command. while persisting operations are atomic, the whole sequence isn't. in the latter case an inconsistency can be eliminated by sending a set_nodename msg again. the nd daemon also includes the OS hostname in the settings message when sending it to ngui. --- build.zig | 1 + src/comm.zig | 7 +- src/lightning/LndConf.zig | 63 ++++++-- src/nd/Config.zig | 164 +++++++++++++++++++-- src/nd/Daemon.zig | 115 +++++++++++++-- src/sys.zig | 28 ++++ src/{nd/SysService.zig => sys/Service.zig} | 0 src/sys/sysimpl.zig | 82 +++++++++++ src/test.zig | 2 +- src/test/guiplay.zig | 20 ++- src/types.zig | 27 ++++ src/ui/c/ui.c | 12 +- src/ui/lvgl.zig | 49 ++++++ src/ui/settings.zig | 116 ++++++++++++++- src/ui/symbol.zig | 1 + src/ui/ui.zig | 9 ++ 16 files changed, 648 insertions(+), 48 deletions(-) create mode 100644 src/sys.zig rename src/{nd/SysService.zig => sys/Service.zig} (100%) create mode 100644 src/sys/sysimpl.zig diff --git a/build.zig b/build.zig index efb561c..92a8e28 100644 --- a/build.zig +++ b/build.zig @@ -112,6 +112,7 @@ pub fn build(b: *std.Build) void { nd.strip = strip; nd.addOptions("build_options", buildopts); nd.addModule("nif", libnif_dep.module("nif")); + nd.addModule("ini", libini); nd.linkLibrary(libnif); const nd_build_step = b.step("nd", "build nd (nakamochi daemon)"); diff --git a/src/comm.zig b/src/comm.zig index 5fb3b12..8e65287 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -85,9 +85,11 @@ pub const MessageTag = enum(u16) { lightning_reset = 0x14, // ngui -> nd: switch sysupdates channel switch_sysupdates = 0x0c, + // ngui -> nd: rename node, both hostname and lnd alias + set_nodename = 0x15, // nd -> ngui: all ndg settings settings = 0x0d, - // next: 0x15 + // next: 0x16 }; /// daemon and gui exchange messages of this type. @@ -111,6 +113,7 @@ pub const Message = union(MessageTag) { lightning_ctrlconn: LightningCtrlConn, lightning_reset: void, switch_sysupdates: SysupdatesChan, + set_nodename: []const u8, settings: Settings, pub const WifiConnect = struct { @@ -247,6 +250,7 @@ pub const Message = union(MessageTag) { }; pub const Settings = struct { + hostname: []const u8, // see .set_nodename sysupdates: struct { channel: SysupdatesChan, }, @@ -341,6 +345,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .lightning_ctrlconn => try json.stringify(msg.lightning_ctrlconn, .{}, data.writer()), .lightning_reset => {}, // zero length payload .switch_sysupdates => try json.stringify(msg.switch_sysupdates, .{}, data.writer()), + .set_nodename => try json.stringify(msg.set_nodename, .{}, data.writer()), .settings => try json.stringify(msg.settings, .{}, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { diff --git a/src/lightning/LndConf.zig b/src/lightning/LndConf.zig index 06f949c..7e2a254 100644 --- a/src/lightning/LndConf.zig +++ b/src/lightning/LndConf.zig @@ -17,6 +17,7 @@ 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"; +pub const AliasKey = "alias"; // a section key/value pairs (properties) followed by its declaration // in square brackets like "[section name]". @@ -141,7 +142,7 @@ pub fn loadReader(allocator: std.mem.Allocator, r: anytype) !LndConf { .section => |name| currsect = try conf.appendSection(name), .property => |kv| { if (currsect == null) { - currsect = try conf.appendSection(MainSection); + currsect = try conf.appendDefaultSection(); } try currsect.?.appendPropStr(kv.key, kv.value); }, @@ -179,10 +180,9 @@ pub fn appendDefaultSection(self: *LndConf) !*Section { /// 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); + var low_name = try std.ascii.allocLowerString(alloc, name); try self.sections.append(.{ - .name = name_dup, + .name = low_name, .props = std.StringArrayHashMap(PropValue).init(alloc), .alloc = alloc, }); @@ -204,13 +204,23 @@ pub fn findSection(self: *const LndConf, name: []const u8) ?*Section { return null; } -fn toLower(s: []u8) void { - for (s, 0..) |c, i| { - switch (c) { - 'A'...'Z' => s[i] = c | 0b00100000, - else => {}, - } - } +/// returns alias field value from the main section. +/// the slice points to a memory owned by LndConf. the callers need not deallocate. +/// if there's no alias defined, returns empty slice. +pub fn alias(self: LndConf) []const u8 { + const main = self.mainSection() orelse return ""; + const val = main.props.get(AliasKey) orelse return ""; + return switch (val) { + .str => |s| s, + .astr => |a| if (a.len > 0) a[0] else "", + }; +} + +/// sets alias field value to the new name. +/// the arg slice is owned by the caller and can be freed upon function return. +pub fn setAlias(self: *LndConf, newname: []const u8) !void { + var main = self.mainSection() orelse try self.appendDefaultSection(); + try main.setPropStr(AliasKey, newname); } test "lnd: conf load dump" { @@ -286,3 +296,34 @@ test "lnd: conf append and dump" { ; try t.expectEqualStrings(want_conf, buf.items); } + +test "lnd: conf alias" { + const t = std.testing; + + var conf = try LndConf.init(t.allocator); + defer conf.deinit(); + try t.expectEqualStrings("", conf.alias()); + + try conf.setAlias("testalias1"); + try t.expectEqualStrings("testalias1", conf.alias()); + var buf = std.ArrayList(u8).init(t.allocator); + defer buf.deinit(); + try conf.dumpWriter(buf.writer()); + const want_alias1 = + \\[application options] + \\alias=testalias1 + \\ + ; + try t.expectEqualStrings(want_alias1, buf.items); + + try conf.setAlias("testalias2"); + try t.expectEqualStrings("testalias2", conf.alias()); + buf.clearAndFree(); + try conf.dumpWriter(buf.writer()); + const want_alias2 = + \\[application options] + \\alias=testalias2 + \\ + ; + try t.expectEqualStrings(want_alias2, buf.items); +} diff --git a/src/nd/Config.zig b/src/nd/Config.zig index 588ac37..2a20f49 100644 --- a/src/nd/Config.zig +++ b/src/nd/Config.zig @@ -4,6 +4,7 @@ const std = @import("std"); const lightning = @import("../lightning.zig"); const types = @import("../types.zig"); +const sys = @import("../sys.zig"); const logger = std.log.scoped(.config); @@ -31,7 +32,9 @@ pub const TOR_DATA_DIR = "/ssd/tor"; arena: *std.heap.ArenaAllocator, // data is allocated here confpath: []const u8, // fs path to where data is persisted +/// any heap-alloc'ed field values are in `arena.allocator()`. static: StaticData, +/// guards `data` as well as `static.hostname` when the latter changed using `setHostname`. mu: std.Thread.RwLock = .{}, data: Data, @@ -43,8 +46,10 @@ pub const Data = struct { sysrunscript: []const u8, }; -/// static data is always interred at init and never changes. +/// static data is interred at init and never changes except for hostname - see `setHostname`. pub const StaticData = struct { + hostname: []const u8, // guarded by self.mu + lnd_user: ?std.process.UserInfo, lnd_tor_hostname: ?[]const u8, bitcoind_rpc_pass: ?[]const u8, }; @@ -69,7 +74,7 @@ pub fn init(allocator: std.mem.Allocator, confpath: []const u8) !Config { .arena = arena, .confpath = confpath, .data = try initData(arena.allocator(), confpath), - .static = inferStaticData(arena.allocator()), + .static = try inferStaticData(arena.allocator()), }; } @@ -117,8 +122,17 @@ fn inferSysupdatesChannel(cron_script_path: []const u8) SysupdatesChannel { return .master; } -fn inferStaticData(allocator: std.mem.Allocator) StaticData { +fn inferStaticData(allocator: std.mem.Allocator) !StaticData { + const hostname = try sys.hostname(allocator); + const lnduser: ?std.process.UserInfo = blk: { + const uid = std.os.linux.getuid(); + const uinfo = types.getUserInfo(LND_OS_USER) catch break :blk null; + // assume there's no lnd user if uid is root or same as current process. + break :blk if (uinfo.uid == 0 or uinfo.uid == uid) null else uinfo; + }; return .{ + .hostname = hostname, + .lnd_user = lnduser, .lnd_tor_hostname = inferLndTorHostname(allocator) catch null, .bitcoind_rpc_pass = inferBitcoindRpcPass(allocator) catch null, }; @@ -154,16 +168,65 @@ fn inferBitcoindRpcPass(allocator: std.mem.Allocator) ![]const u8 { } /// calls F while holding a readonly lock and passes on F's result as is. +/// F is expected to take `Data` and `StaticData` args. pub fn safeReadOnly(self: *Config, comptime F: anytype) @typeInfo(@TypeOf(F)).Fn.return_type.? { self.mu.lockShared(); defer self.mu.unlockShared(); - return F(self.data); + return F(self.data, self.static); } +/// used by mutateLndConf to guard concurrent access. +var lndconf_mu: std.Thread.Mutex = .{}; + +pub const MutateLndConfOpt = struct { + filepath: ?[]const u8 = null, // lnd conf file name; defaults to LND_CONF_PATH +}; + +/// allows callers to serialize access to an lnd config file. +pub fn beginMutateLndConf(self: *Config, opt: MutateLndConfOpt) !LndConfMut { + lndconf_mu.lock(); + errdefer lndconf_mu.unlock(); + const allocator = self.arena.child_allocator; + const filepath = opt.filepath orelse LND_CONF_PATH; + return .{ + .lndconf = try lightning.LndConf.load(allocator, filepath), + .allocator = allocator, + .filepath = filepath, + .lnduser = self.static.lnd_user, + .mu = &lndconf_mu, + }; +} + +pub const LndConfMut = struct { + lndconf: lightning.LndConf, + + allocator: std.mem.Allocator, + filepath: []const u8, + lnduser: ?std.process.UserInfo = null, + mu: *std.Thread.Mutex, + + pub fn persist(self: @This()) !void { + const file = try std.io.BufferedAtomicFile.create(self.allocator, std.fs.cwd(), self.filepath, .{ .mode = 0o400 }); + defer file.destroy(); // frees resources; does NOT delete the file + try self.lndconf.dumpWriter(file.writer()); + try file.finish(); // persist the file in the correct location + // change ownership to that of the lnd sys user + if (self.lnduser) |user| { + try chown(self.filepath, user); + } + } + + /// relinquish concurrent access guard and resources. + pub fn finish(self: @This()) void { + defer self.mu.unlock(); + self.lndconf.deinit(); + } +}; + /// stores current `Config.data` to disk, into `Config.confpath`. pub fn dump(self: *Config) !void { - self.mu.lock(); - defer self.mu.unlock(); + self.mu.lockShared(); + defer self.mu.unlockShared(); return self.dumpUnguarded(); } @@ -176,6 +239,22 @@ fn dumpUnguarded(self: Config) !void { try file.finish(); } +/// sets hostname to a new name at runtime in both the OS and `Config.static.hostname`. +/// see `sys.setHostname` for `newname` sanitization rules. +/// the name arg must outlive this function call. +/// safe for concurrent use. +pub fn setHostname(self: *Config, newname: []const u8) !void { + self.mu.lock(); // for self.static.hostname + defer self.mu.unlock(); + const allocator = self.arena.allocator(); + + const dupname = try allocator.dupe(u8, newname); + errdefer allocator.free(dupname); + try sys.setHostname(allocator, newname); + allocator.free(self.static.hostname); + self.static.hostname = dupname; +} + /// when run is set, executes the update after changing the channel. /// executing an update may terminate and start a new nd+ngui instance. pub fn switchSysupdates(self: *Config, chan: SysupdatesChannel, opt: struct { run: bool }) !void { @@ -214,6 +293,8 @@ fn genSysupdatesCronScript(self: Config) !void { /// the scriptpath is typically the cronjob script, not a SYSUPDATES_RUN_SCRIPT /// because the latter requires command args which is what cron script does. +/// +/// the caller must serialize this function calls. fn runSysupdates(allocator: std.mem.Allocator, scriptpath: []const u8) !void { const res = try std.ChildProcess.exec(.{ .allocator = allocator, .argv = &.{scriptpath} }); defer { @@ -260,6 +341,7 @@ pub fn lndConnectWaitMacaroonFile(self: Config, allocator: std.mem.Allocator, ty .tor_http => 10010, }; return std.fmt.allocPrint(allocator, "lndconnect://{[host]s}:{[port]d}?macaroon={[macaroon]s}", .{ + // TODO: return an error instead and propagate to the UI .host = self.static.lnd_tor_hostname orelse ".onion", .port = port, .macaroon = macaroon_b64, @@ -272,7 +354,6 @@ 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 types.getUserInfo(LND_OS_USER); const allocator = self.arena.child_allocator; const opt = .{ .mode = 0o400 }; @@ -284,24 +365,20 @@ pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize const hex = try std.fmt.bufPrint(outbuf, "{}", .{std.fmt.fmtSliceHexLower(&raw_unlock_pwd)}); try file.writer().writeAll(hex); try file.finish(); - - const f = try std.fs.cwd().openFile(filepath, .{}); - defer f.close(); - try f.chown(lnduser.uid, lnduser.gid); + try self.chownLndUser(filepath); return hex; } /// options for genLndConfig. -pub const LndConfOpt = struct { +pub const GenLndConfOpt = 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 { +pub fn genLndConfig(self: Config, opt: GenLndConfOpt) !void { const confpath = opt.path orelse LND_CONF_PATH; - const lnduser = try types.getUserInfo(LND_OS_USER); const allocator = self.arena.child_allocator; var conf = try lightning.LndConf.init(allocator); @@ -358,9 +435,20 @@ pub fn genLndConfig(self: Config, opt: LndConfOpt) !void { 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, .{}); + try self.chownLndUser(confpath); +} + +/// changes a file ownership to that of `LND_OS_USER`, if the user exists. +fn chownLndUser(self: Config, filepath: []const u8) !void { + if (self.static.lnd_user) |user| { + try chown(filepath, user); + } +} + +fn chown(filepath: []const u8, user: std.process.UserInfo) !void { + const f = try std.fs.cwd().openFile(filepath, .{}); defer f.close(); - try f.chown(lnduser.uid, lnduser.gid); + try f.chown(user.uid, user.gid); } test "ndconfig: init existing" { @@ -520,6 +608,8 @@ test "ndconfig: genLndConfig" { .sysrunscript = undefined, // unused }, .static = .{ + .hostname = "testhost", + .lnd_user = null, .lnd_tor_hostname = "test.onion", .bitcoind_rpc_pass = "test secret", }, @@ -549,3 +639,45 @@ test "ndconfig: genLndConfig" { try t.expect(lndconf.findSection("autopilot") != null); try t.expect(lndconf.findSection("tor") != null); } + +test "ndconfig: mutate LndConf" { + 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(t.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + var conf = Config{ + .arena = conf_arena, + .confpath = undefined, // unused + .data = undefined, // unused + .static = .{ + .lnd_user = try types.getUserInfo("ignored"), + .hostname = undefined, + .lnd_tor_hostname = null, + .bitcoind_rpc_pass = null, + }, + }; + defer conf.deinit(); + const lndconf_path = try tmp.join(&.{"lndconf.ini"}); + try tmp.dir.writeFile(lndconf_path, + \\[application options] + \\alias=noname + \\ + ); + var mut = try conf.beginMutateLndConf(.{ .filepath = lndconf_path }); + try mut.lndconf.setAlias("newalias"); + try mut.persist(); + mut.finish(); + + const cont = try tmp.dir.readFileAlloc(t.allocator, lndconf_path, 1 << 10); + defer t.allocator.free(cont); + try t.expectEqualStrings( + \\[application options] + \\alias=newalias + \\ + , cont); +} diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 9fe0413..640bcba 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -21,7 +21,7 @@ const Config = @import("Config.zig"); const lndhttp = @import("../lightning.zig").lndhttp; const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); -const SysService = @import("SysService.zig"); +const sys = @import("../sys.zig"); const types = @import("../types.zig"); const logger = std.log.scoped(.daemon); @@ -67,11 +67,12 @@ lnd_timer: time.Timer, lnd_report_interval: u64 = 1 * time.ns_per_min, lnd_tls_reset_count: usize = 0, +// TODO: move this to a sys.ServiceList /// system services actively managed by the daemon. /// these are stop'ed during poweroff and their shutdown progress sent to ngui. /// initialized in start and never modified again: ok to access without holding self.mu. services: struct { - list: []SysService, + list: []sys.Service, fn stopWait(self: @This(), name: []const u8) !void { for (self.list) |*sv| { @@ -127,15 +128,15 @@ const InitOpt = struct { /// and a filesystem path to WPA control socket. /// callers must deinit when done. pub fn init(opt: InitOpt) !Daemon { - var svlist = std.ArrayList(SysService).init(opt.allocator); + var svlist = std.ArrayList(sys.Service).init(opt.allocator); errdefer { for (svlist.items) |*sv| sv.deinit(); svlist.deinit(); } // the order is important. when powering off, the services are shut down // in the same order appended here. - try svlist.append(SysService.init(opt.allocator, SysService.LND, .{ .stop_wait_sec = 600 })); - try svlist.append(SysService.init(opt.allocator, SysService.BITCOIND, .{ .stop_wait_sec = 600 })); + try svlist.append(sys.Service.init(opt.allocator, sys.Service.LND, .{ .stop_wait_sec = 600 })); + try svlist.append(sys.Service.init(opt.allocator, sys.Service.BITCOIND, .{ .stop_wait_sec = 600 })); const conf = try Config.init(opt.allocator, opt.confpath); errdefer conf.deinit(); @@ -334,8 +335,9 @@ fn mainThreadLoopCycle(self: *Daemon) !void { if (self.want_settings) { const ok = self.conf.safeReadOnly(struct { - fn f(conf: Config.Data) bool { + fn f(conf: Config.Data, static: Config.StaticData) bool { const msg: comm.Message.Settings = .{ + .hostname = static.hostname, .sysupdates = .{ .channel = switch (conf.syschannel) { .dev => .edge, @@ -456,6 +458,12 @@ fn commThreadLoop(self: *Daemon) void { // TODO: send err back to ngui }; }, + .set_nodename => |newname| { + self.setNodename(newname) catch |err| { + logger.err("setNodename: {!}", .{err}); + // TODO: send err back to ngui + }; + }, .lightning_genseed => { self.generateWalletSeed() catch |err| { logger.err("generateWalletSeed: {!}", .{err}); @@ -1005,8 +1013,8 @@ fn initWallet(self: *Daemon, req: comm.Message.LightningInitWallet) !void { // restart the lnd service to pick up the newly generated config above. logger.info("initwallet: restarting lnd", .{}); - try self.services.stopWait(SysService.LND); - try self.services.start(SysService.LND); + try self.services.stopWait(sys.Service.LND); + try self.services.start(sys.Service.LND); var timer = try types.Timer.start(); while (timer.read() < 10 * time.ns_per_s) { const status = client.call(.walletstatus, {}) catch |err| { @@ -1065,7 +1073,7 @@ fn resetLndNode(self: *Daemon) !void { self.mu.unlock(); // 1. stop lnd service - try self.services.stopWait(SysService.LND); + try self.services.stopWait(sys.Service.LND); // 2. delete all data directories try std.fs.cwd().deleteTree(Config.LND_DATA_DIR); @@ -1081,7 +1089,7 @@ fn resetLndNode(self: *Daemon) !void { try self.conf.genLndConfig(.{ .autounlock = false }); // 4. start lnd service - try self.services.start(SysService.LND); + try self.services.start(sys.Service.LND); } /// like resetLndNode but resets only tls certs, nothing else. @@ -1105,8 +1113,8 @@ fn resetLndTlsUnguarded(self: *Daemon) !void { logger.info("resetting lnd tls certs", .{}); try std.fs.cwd().deleteFile(Config.LND_TLSKEY_PATH); try std.fs.cwd().deleteFile(Config.LND_TLSCERT_PATH); - try self.services.stopWait(SysService.LND); - try self.services.start(SysService.LND); + try self.services.stopWait(sys.Service.LND); + try self.services.start(sys.Service.LND); self.lnd_tls_reset_count += 1; } @@ -1130,6 +1138,85 @@ fn switchSysupdatesThread(self: *Daemon, chan: comm.Message.SysupdatesChan) void self.want_settings = true; } +/// reconfigures hostname and lnd alias in a detached thread. +/// the procedure is not atomic and may leave names in inconsistent state. +/// +/// `newname` must not exceed max hostname length on the running system. +/// ascii control characters are ignored except for \n, \t and \r which are +/// replaced by a space. while utf8 codepoints are preserved in lnd alias, +/// they are removed when setting hostname. +/// +/// required `newname` lifetime is only until the function returns. +fn setNodename(self: *Daemon, newname: []const u8) !void { + // newly alloc'ed namesan is freed in the detached thread. + const namesan = try allocSanitizeNodename(self.allocator, newname); + const th = try std.Thread.spawn(.{}, setNodenameThread, .{ self, namesan }); + th.detach(); +} + +/// owns `newname` and frees all resources using `self.allocator`. +fn setNodenameThread(self: *Daemon, newname: []const u8) void { + defer self.allocator.free(newname); + self.setNodenameInternal(newname) catch |err| { + logger.err("setNodenameIternal: {!}", .{err}); + // TODO: send err back to ngui + }; +} + +/// assumes `newname` is sanitized for lnd alias. +/// the args must be alive until the function return. +fn setNodenameInternal(self: *Daemon, newname: []const u8) !void { + // change lnd alias + var mut = try self.conf.beginMutateLndConf(.{}); + defer { + mut.finish(); // relinquish concurrent access guard and resources + logger.debug("exiting setNodenameInternal thread", .{}); + } + if (!std.mem.eql(u8, newname, mut.lndconf.alias())) { + try mut.lndconf.setAlias(newname); + try mut.persist(); // store config changes on disk + try self.services.stopWait(sys.Service.LND); + try self.services.start(sys.Service.LND); + } + logger.debug("changed lnd alias to {s}", .{newname}); + + // change the hostname + try self.conf.setHostname(newname); + logger.debug("changed hostname to {s}", .{newname}); + + // notify the UI + self.mu.lock(); + self.want_settings = true; + self.mu.unlock(); +} + +/// replaces whitespace with space literal and ignores ascii control chars. +/// caller owns returned value. +fn allocSanitizeNodename(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { + if (name.len == 0 or try std.unicode.utf8CountCodepoints(name) > std.os.HOST_NAME_MAX) { + return error.InvalidNodenameLength; + } + var sanitized = try std.ArrayList(u8).initCapacity(allocator, name.len); + defer sanitized.deinit(); + var it = (try std.unicode.Utf8View.init(name)).iterator(); + while (it.nextCodepointSlice()) |s| { + if (s.len == 1) switch (s[0]) { + // replace whitespace chars with a space literal + '\t', '\n', '\r', std.ascii.control_code.vt, std.ascii.control_code.ff => try sanitized.append(' '), + else => |c| { + // ignore control ascii + if (std.ascii.isControl(c)) continue; + try sanitized.append(c); + }, + } else { + // leave utf8 codepoints as is + try sanitized.appendSlice(s); + } + } + const trimmed = std.mem.trim(u8, sanitized.items, &std.ascii.whitespace); + return allocator.dupe(u8, trimmed); +} + test "start-stop" { const t = std.testing; @@ -1168,7 +1255,7 @@ test "start-stop" { try t.expect(daemon.services.list.len > 0); for (daemon.services.list) |*sv| { try t.expect(!sv.stop_proc.spawned); - try t.expectEqual(SysService.Status.initial, sv.status()); + try t.expectEqual(sys.Service.Status.initial, sv.status()); } daemon.deinit(); @@ -1214,7 +1301,7 @@ test "start-poweroff" { for (daemon.services.list) |*sv| { try t.expect(sv.stop_proc.spawned); try t.expect(sv.stop_proc.waited); - try t.expectEqual(SysService.Status.stopped, sv.status()); + try t.expectEqual(sys.Service.Status.stopped, sv.status()); } const msg1 = try comm.read(arena, gui_reader); diff --git a/src/sys.zig b/src/sys.zig new file mode 100644 index 0000000..3171acd --- /dev/null +++ b/src/sys.zig @@ -0,0 +1,28 @@ +//! operating system related helper functions. + +const builtin = @import("builtin"); +const std = @import("std"); + +const types = @import("types.zig"); +const sysimpl = @import("sys/sysimpl.zig"); + +pub const Service = @import("sys/Service.zig"); + +pub usingnamespace if (builtin.is_test) struct { + // stubs, mocks and overrides for testing. + + pub fn hostname(allocator: std.mem.Allocator) ![]const u8 { + return allocator.dupe(u8, "testhost"); + } + + pub fn setHostname(allocator: std.mem.Allocator, name: []const u8) !void { + _ = allocator; + _ = name; + } +} else sysimpl; // real implementation for production code. + +test { + _ = @import("sys/Service.zig"); + _ = @import("sys/sysimpl.zig"); + std.testing.refAllDecls(@This()); +} diff --git a/src/nd/SysService.zig b/src/sys/Service.zig similarity index 100% rename from src/nd/SysService.zig rename to src/sys/Service.zig diff --git a/src/sys/sysimpl.zig b/src/sys/sysimpl.zig new file mode 100644 index 0000000..137a6ab --- /dev/null +++ b/src/sys/sysimpl.zig @@ -0,0 +1,82 @@ +//! real implementation of the sys module for production code. + +const std = @import("std"); +const types = @import("../types.zig"); + +/// caller owns memory; must dealloc using `allocator`. +pub fn hostname(allocator: std.mem.Allocator) ![]const u8 { + var buf: [std.os.HOST_NAME_MAX]u8 = undefined; + const name = try std.os.gethostname(&buf); + return allocator.dupe(u8, name); +} + +/// a variable for tests; must not mutate at runtime otherwise. +var hostname_filepath: []const u8 = "/etc/hostname"; + +/// removes all non-alphanumeric ascii and utf8 codepoints when setting hostname, +/// as well as leading digits. +pub fn setHostname(allocator: std.mem.Allocator, name: []const u8) !void { + // sanitize the new input. + var sanitized = try std.ArrayList(u8).initCapacity(allocator, name.len); + defer sanitized.deinit(); + var it = (try std.unicode.Utf8View.init(name)).iterator(); + while (it.nextCodepointSlice()) |s| { + if (s.len != 1) continue; + switch (s[0]) { + 'A'...'Z', 'a'...'z' => |c| try sanitized.append(c), + '0'...'9' => |c| { + if (sanitized.items.len == 0) { + // ignore leading digits + continue; + } + try sanitized.append(c); + }, + else => {}, // ignore non-alphanumeric + } + } + if (sanitized.items.len == 0) { + return error.SetHostnameEmptyName; + } + const newname = sanitized.items; + + // need not continue if current name matches the new one. + var buf: [std.os.HOST_NAME_MAX]u8 = undefined; + const currname = try std.os.gethostname(&buf); + if (std.mem.eql(u8, currname, newname)) { + return; + } + + // make persistent change first + const opt = .{ .mode = 0o644 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), hostname_filepath, opt); + defer file.destroy(); // releases resources; does NOT deletes the file + try file.writer().writeAll(newname); + try file.finish(); + + // rename hostname on the running system + var proc = types.ChildProcess.init(&.{ "hostname", newname }, allocator); + switch (try proc.spawnAndWait()) { + .Exited => |code| if (code != 0) return error.SetHostnameBadExitCode, + else => return error.SetHostnameBadTerm, + } +} + +test "setHostname" { + const t = std.testing; + const tt = @import("../test.zig"); + // need to manual free resources because no way to deinit the child process spawn in setHostname. + var arena_state = std.heap.ArenaAllocator.init(t.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + hostname_filepath = try tmp.join(&.{"hostname"}); + try tmp.dir.writeFile(hostname_filepath, "dummy"); + + try setHostname(arena, "123_-newhostname$%/3-4hello5\xef\x83\xa7end"); + + var buf: [128]u8 = undefined; + const cont = try tmp.dir.readFile(hostname_filepath, &buf); + try t.expectEqualStrings("newhostname34hello5end", cont); +} diff --git a/src/test.zig b/src/test.zig index 0fa528c..5fa23e8 100644 --- a/src/test.zig +++ b/src/test.zig @@ -328,9 +328,9 @@ pub fn expectNoSubstring(needle: []const u8, haystack: []const u8) !void { test { _ = @import("nd.zig"); _ = @import("nd/Daemon.zig"); - _ = @import("nd/SysService.zig"); _ = @import("ngui.zig"); _ = @import("lightning.zig"); + _ = @import("sys.zig"); std.testing.refAllDecls(@This()); } diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 59b5948..65401a7 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -3,6 +3,7 @@ const time = std.time; const os = std.os; const comm = @import("comm"); +const types = @import("../types.zig"); const logger = std.log.scoped(.play); const stderr = std.io.getStdErr().writer(); @@ -72,6 +73,11 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { return flags; } +/// global vars for comm read/write threads +var mu: std.Thread.Mutex = .{}; +var nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}; +var settings_sent = false; + fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err}); @@ -137,6 +143,12 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { }; comm.write(gpa, w, .{ .lightning_ctrlconn = conn }) catch |err| logger.err("{!}", .{err}); }, + .set_nodename => |s| { + mu.lock(); + defer mu.unlock(); + nodename.set(s); + settings_sent = false; + }, else => {}, } } @@ -148,8 +160,6 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var sectimer = try time.Timer.start(); var block_count: u32 = 801365; - var settings_sent = false; - var lnd_uninited_sent = false; while (true) { @@ -159,9 +169,13 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { } sectimer.reset(); + mu.lock(); + defer mu.unlock(); + if (!settings_sent) { settings_sent = true; const sett: comm.Message.Settings = .{ + .hostname = nodename.val(), .sysupdates = .{ .channel = .edge }, }; comm.write(gpa, w, .{ .settings = sett }) catch |err| { @@ -292,6 +306,8 @@ pub fn main() !void { const flags = try parseArgs(gpa); defer flags.deinit(gpa); + nodename.set("guiplayhost"); + ngui_proc = std.ChildProcess.init(&.{flags.ngui_path.?}, gpa); ngui_proc.stdin_behavior = .Pipe; ngui_proc.stdout_behavior = .Pipe; diff --git a/src/types.zig b/src/types.zig index 7fe3956..689567a 100644 --- a/src/types.zig +++ b/src/types.zig @@ -62,6 +62,33 @@ pub const IoPipe = struct { } }; +pub fn BufTrimString(comptime maxlength: usize) type { + return struct { + buf: [maxlength]u8 = undefined, + len: usize = 0, + + pub fn set(self: *@This(), s: []const u8) void { + const newlen = @min(maxlength, s.len); + @memcpy(@as([*]u8, &self.buf), s[0..newlen]); + self.len = newlen; + } + + pub fn val(self: *const @This()) []const u8 { + return self.buf[0..self.len]; + } + }; +} + +test "BufTrimString" { + const t = std.testing; + var bs = BufTrimString(5){}; + try t.expectEqualStrings("", bs.val()); + bs.set("hello"); + try t.expectEqualStrings("hello", bs.val()); + bs.set("hellahello"); + try t.expectEqualStrings("hella", bs.val()); +} + pub const StringList = struct { l: std.ArrayList([]const u8), allocator: std.mem.Allocator, diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index 4a74fe1..8737f75 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -30,6 +30,11 @@ int nm_create_bitcoin_panel(lv_obj_t *parent); */ int nm_create_lightning_panel(lv_obj_t *parent); +/** + * creates nodename card of the settings panel. + */ +lv_obj_t *nm_create_settings_nodename(lv_obj_t *parent); + /** * creates the sysupdates section of the settings panel. */ @@ -248,6 +253,7 @@ static int create_settings_panel(lv_obj_t *parent) * sysupdates panel ********************/ // ported to zig; + lv_obj_t *nodename_panel = nm_create_settings_nodename(parent); lv_obj_t *sysupdates_panel = nm_create_settings_sysupdates(parent); /******************** @@ -256,13 +262,15 @@ static int create_settings_panel(lv_obj_t *parent) static lv_coord_t parent_grid_cols[] = {LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; static lv_coord_t parent_grid_rows[] = {/**/ LV_GRID_CONTENT, /* wifi panel */ + LV_GRID_CONTENT, /* nodename panel */ LV_GRID_CONTENT, /* power panel */ LV_GRID_CONTENT, /* sysupdates panel */ LV_GRID_TEMPLATE_LAST}; lv_obj_set_grid_dsc_array(parent, parent_grid_cols, parent_grid_rows); lv_obj_set_grid_cell(wifi_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 0, 1); - lv_obj_set_grid_cell(power_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 1, 1); - lv_obj_set_grid_cell(sysupdates_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 2, 1); + lv_obj_set_grid_cell(nodename_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 1, 1); + lv_obj_set_grid_cell(power_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 2, 1); + lv_obj_set_grid_cell(sysupdates_panel, LV_GRID_ALIGN_STRETCH, 0, 1, LV_GRID_ALIGN_CENTER, 3, 1); static lv_coord_t wifi_grid_cols[] = {LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST}; static lv_coord_t wifi_grid_rows[] = {/**/ diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 977fb29..152f535 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -774,6 +774,48 @@ pub const TextButton = struct { } }; +pub const TextArea = struct { + lvobj: *LvObj, + + pub usingnamespace BaseObjMethods; + pub usingnamespace WidgetMethods; + pub usingnamespace InteractiveMethods; + + pub const Opt = struct { + oneline: bool = true, + password_mode: bool = false, + maxlen: ?u32 = null, + }; + + pub fn new(parent: anytype, opt: Opt) !TextArea { + const obj = lv_textarea_create(parent.lvobj) orelse return error.OutOfMemory; + const ta: TextArea = .{ .lvobj = obj }; + ta.setOpt(opt); + return ta; + } + + pub fn setOpt(self: TextArea, opt: Opt) void { + lv_textarea_set_one_line(self.lvobj, opt.oneline); + lv_textarea_set_password_mode(self.lvobj, opt.password_mode); + if (opt.maxlen) |n| { + lv_textarea_set_max_length(self.lvobj, n); + } + } + + /// `text` arg is heap-duplicated by LVGL's alloc and owned by this text area object. + pub fn setText(self: TextArea, txt: [:0]const u8) void { + lv_textarea_set_text(self.lvobj, txt.ptr); + } + + /// returned value is still owned by `TextArea`. + pub fn text(self: TextArea) []const u8 { + const buf = lv_textarea_get_text(self.lvobj) orelse return ""; + //const slice: [:0]const u8 = std.mem.span(buf); + //return slice; + return std.mem.span(buf); + } +}; + pub const Spinner = struct { lvobj: *LvObj, @@ -1172,6 +1214,13 @@ extern fn lv_label_set_text_static(label: *LvObj, text: [*:0]const u8) void; extern fn lv_label_set_long_mode(label: *LvObj, mode: c.lv_label_long_mode_t) void; extern fn lv_label_set_recolor(label: *LvObj, enable: bool) void; +extern fn lv_textarea_create(parent: *LvObj) ?*LvObj; +extern fn lv_textarea_get_text(obj: *LvObj) ?[*:0]const u8; +extern fn lv_textarea_set_max_length(obj: *LvObj, n: u32) void; +extern fn lv_textarea_set_one_line(obj: *LvObj, enable: bool) void; +extern fn lv_textarea_set_password_mode(obj: *LvObj, enable: bool) void; +extern fn lv_textarea_set_text(obj: *LvObj, text: [*:0]const u8) void; + extern fn lv_dropdown_create(parent: *LvObj) ?*LvObj; extern fn lv_dropdown_set_text(obj: *LvObj, text: ?[*:0]const u8) void; extern fn lv_dropdown_set_options(obj: *LvObj, options: [*:0]const u8) void; diff --git a/src/ui/settings.zig b/src/ui/settings.zig index c022361..c9eb298 100644 --- a/src/ui/settings.zig +++ b/src/ui/settings.zig @@ -6,19 +6,32 @@ const std = @import("std"); const comm = @import("../comm.zig"); +const types = @import("../types.zig"); const lvgl = @import("lvgl.zig"); const symbol = @import("symbol.zig"); +const widget = @import("widget.zig"); const logger = std.log.scoped(.ui); /// label color mark start to make "label:" part of a "label: value" /// in a different color. const cmark = "#bbbbbb "; -/// button text +/// buttons text const textSwitch = "SWITCH"; +const textChange = "CHANGE"; + +// global allocator set in init. +// must be set before any call into pub funcs in this module. +pub var allocator: std.mem.Allocator = undefined; /// the settings tab alive for the whole duration of the process. var tab: struct { + nodename: struct { + card: lvgl.Card, + currname: lvgl.Label, + textarea: lvgl.TextArea, + changebtn: lvgl.TextButton, + }, sysupdates: struct { card: lvgl.Card, chansel: lvgl.Dropdown, @@ -29,9 +42,58 @@ var tab: struct { /// holds last values received from the daemon. var state: struct { + nodename_change_inprogress: bool = false, + curr_nodename: types.BufTrimString(std.os.HOST_NAME_MAX) = .{}, curr_sysupdates_chan: ?comm.Message.SysupdatesChan = null, } = .{}; +/// creates a settings panel allowing to change hostname and lnd alias, +/// aka nodename. +pub fn initNodenamePanel(cont: lvgl.Container) !lvgl.Card { + tab.nodename.card = try lvgl.Card.new(cont, symbol.Edit ++ " NODE NAME", .{ .spinner = true }); + + const row = try lvgl.FlexLayout.new(tab.nodename.card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.setHeightToContent(); + + // left column + const left = try lvgl.FlexLayout.new(row, .column, .{ .height = .content }); + left.flexGrow(1); + left.setPad(10, .row, .{}); + + tab.nodename.currname = try lvgl.Label.new(left, cmark ++ "CURRENT NAME:# unknown", .{ .recolor = true }); + tab.nodename.currname.setHeightToContent(); + + const lab = try lvgl.Label.new(left, "the name is visible on a local network as well as lightning.", .{}); + lab.setWidth(lvgl.sizePercent(100)); + lab.setHeightToContent(); + lab.setPad(0, .right, .{}); + + // right column + const right = try lvgl.FlexLayout.new(row, .column, .{ .height = .content }); + right.flexGrow(1); + right.setPad(10, .row, .{}); + right.setPad(0, .column, .{}); + + tab.nodename.textarea = try lvgl.TextArea.new(right, .{ + .maxlen = std.os.HOST_NAME_MAX, + .oneline = true, + }); + tab.nodename.textarea.setWidth(lvgl.sizePercent(100)); + _ = tab.nodename.textarea.on(.all, nm_nodename_textarea_input, null); + + tab.nodename.changebtn = try lvgl.TextButton.new(right, textChange); + tab.nodename.changebtn.setWidth(lvgl.sizePercent(100)); + tab.nodename.changebtn.setPad(0, .left, .{}); + + // disable name change 'till data received from the daemon. + tab.nodename.textarea.disable(); + tab.nodename.changebtn.disable(); + _ = tab.nodename.changebtn.on(.click, nm_nodename_change_btn_click, null); + + return tab.nodename.card; +} + /// creates a settings panel UI to control system updates channel. /// must be called only once at program startup. pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card { @@ -83,9 +145,61 @@ pub fn initSysupdatesPanel(cont: lvgl.Container) !lvgl.Card { /// updates the UI with the data from the provided settings arg. pub fn update(sett: comm.Message.Settings) !void { + // sysupdates channel var buf: [512]u8 = undefined; try tab.sysupdates.currchan.setTextFmt(&buf, cmark ++ "CURRENT CHANNEL:# {s}", .{@tagName(sett.sysupdates.channel)}); state.curr_sysupdates_chan = sett.sysupdates.channel; + + // nodename + state.curr_nodename.set(sett.hostname); + try tab.nodename.currname.setTextFmt(&buf, cmark ++ "CURRENT NAME:# {s}", .{state.curr_nodename.val()}); + if (state.nodename_change_inprogress) { + const currname = tab.nodename.textarea.text(); + if (std.mem.eql(u8, sett.hostname, currname)) { + state.nodename_change_inprogress = false; + tab.nodename.textarea.setText(""); + tab.nodename.textarea.enable(); + tab.nodename.card.spin(.off); + } + } else { + tab.nodename.textarea.enable(); + const currname = state.curr_nodename.val(); + const newname = tab.nodename.textarea.text(); + if (newname.len > 0 and !std.mem.eql(u8, newname, currname)) { + tab.nodename.changebtn.enable(); + } else { + tab.nodename.changebtn.disable(); + } + } +} + +export fn nm_nodename_textarea_input(e: *lvgl.LvEvent) void { + switch (e.code()) { + .focus => widget.keyboardOn(tab.nodename.textarea), + .defocus, .ready, .cancel => widget.keyboardOff(), + .value_changed => { + const currname = state.curr_nodename.val(); + const newname = tab.nodename.textarea.text(); + if (currname.len > 0 and newname.len > 0 and !std.mem.eql(u8, newname, currname)) { + tab.nodename.changebtn.enable(); + } else { + tab.nodename.changebtn.disable(); + } + }, + else => {}, + } +} + +export fn nm_nodename_change_btn_click(_: *lvgl.LvEvent) void { + const newname = tab.nodename.textarea.text(); + comm.pipeWrite(.{ .set_nodename = newname }) catch |err| { + logger.err("nodename change pipe write: {!}", .{err}); + return; + }; + state.nodename_change_inprogress = true; + tab.nodename.changebtn.disable(); + tab.nodename.textarea.disable(); + tab.nodename.card.spin(.on); } export fn nm_sysupdates_chansel_changed(_: *lvgl.LvEvent) void { diff --git a/src/ui/symbol.zig b/src/ui/symbol.zig index 8cbe1fc..22e58c7 100644 --- a/src/ui/symbol.zig +++ b/src/ui/symbol.zig @@ -1,4 +1,5 @@ ///! see lv_symbols_def.h +pub const Edit = &[_]u8{ 0xef, 0x8c, 0x84 }; pub const LightningBolt = &[_]u8{ 0xef, 0x83, 0xa7 }; pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 }; pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c }; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 06e71cd..a9576bd 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -24,6 +24,7 @@ var allocator: std.mem.Allocator = undefined; pub fn init(gpa: std.mem.Allocator) !void { allocator = gpa; + settings.allocator = gpa; lvgl.init(); const disp = try drv.initDisplay(); drv.initInput() catch |err| { @@ -61,6 +62,14 @@ export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_settings_nodename(parent: *lvgl.LvObj) ?*lvgl.LvObj { + const card = settings.initNodenamePanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("initNodenamePanel: {any}", .{err}); + return null; + }; + return card.lvobj; +} + export fn nm_create_settings_sysupdates(parent: *lvgl.LvObj) ?*lvgl.LvObj { const card = settings.initSysupdatesPanel(lvgl.Container{ .lvobj = parent }) catch |err| { logger.err("initSysupdatesPanel: {any}", .{err});