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});