nd,ui: add a new facility to be able to change node name
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/tag/woodpecker Pipeline was successful Details

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 <newname>" 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.
master v0.7.0
alex 6 months ago
parent 836f196a44
commit a080e1ac79
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -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)");

@ -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)) {

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

@ -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 "<no-tor-hostname>.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);
}

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

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

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

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

@ -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;

@ -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,

@ -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[] = {/**/

@ -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;

@ -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 {

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

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