From b57ebacd4abb7131a5e84965bcc1deb55f9c87c8 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 13 Oct 2023 16:29:20 +0200 Subject: [PATCH] nd,ngui: add lightning setup process, phone pairing and reset the daemon can now identify which state lnd is in and report to the GUI which displays different elements on the lighting tab, based on the reported lnd state: - startup in progress: displays spinner - no lightning wallet: displays a "setup" button and guides the user through a simple workflow - reset an operational node this means, the UI has now minimal but functional elements for any user equipped with a smart phone to set up the lightning node without SSHing in or using command line in general. --- src/comm.zig | 75 ++++++- src/lndhttp.zig | 154 ++++++++++++-- src/nd/Config.zig | 189 ++++++++++++++++- src/nd/Daemon.zig | 377 ++++++++++++++++++++++++++++++---- src/nd/SysService.zig | 48 ++++- src/ngui.zig | 33 ++- src/test/guiplay.zig | 26 +++ src/test/lndhc.zig | 21 +- src/types.zig | 13 +- src/ui/c/lv_conf.h | 2 +- src/ui/c/ui.c | 10 + src/ui/lightning.zig | 460 +++++++++++++++++++++++++++++++++++++++--- src/ui/lvgl.zig | 62 +++++- src/ui/symbol.zig | 2 + src/ui/ui.zig | 11 +- 15 files changed, 1374 insertions(+), 109 deletions(-) diff --git a/src/comm.zig b/src/comm.zig index 618b31a..5fb3b12 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -69,11 +69,25 @@ pub const MessageTag = enum(u16) { onchain_report = 0x0a, // nd -> ngui: lnd status and stats report lightning_report = 0x0b, + // nd -> ngui: error report when not in a regular running mode + lightning_error = 0x0e, + // ngui -> nd: call lnd to generate a new seed during initial setup + lightning_genseed = 0x0f, + // nd -> ngui: the result of genseed + lightning_genseed_result = 0x10, + // ngui -> nd: proceed with initializing a new or existing wallet + lightning_init_wallet = 0x11, + // ngui -> nd: request connection URLs for a controller app + lightning_get_ctrlconn = 0x12, + // nd -> ngui: lightning_get_ctrlconn result + lightning_ctrlconn = 0x13, + // ngui -> nd: factory reset lnd node; wipes out the wallet + lightning_reset = 0x14, // ngui -> nd: switch sysupdates channel switch_sysupdates = 0x0c, // nd -> ngui: all ndg settings settings = 0x0d, - // next: 0x0e + // next: 0x15 }; /// daemon and gui exchange messages of this type. @@ -89,6 +103,13 @@ pub const Message = union(MessageTag) { poweroff_progress: PoweroffProgress, onchain_report: OnchainReport, lightning_report: LightningReport, + lightning_error: LightningError, + lightning_genseed: LightningGenSeed, + lightning_genseed_result: []const []const u8, + lightning_init_wallet: LightningInitWallet, + lightning_get_ctrlconn: void, + lightning_ctrlconn: LightningCtrlConn, + lightning_reset: void, switch_sysupdates: SysupdatesChan, settings: Settings, @@ -188,6 +209,38 @@ pub const Message = union(MessageTag) { }, }; + pub const LightningCtrlConn = []const LnCtrlConnItem; + + pub const LnCtrlConnItem = struct { + url: []const u8, + typ: enum { lnd_rpc, lnd_http }, + perm: enum { admin }, // TODO: support read-only and invoice-only permissions + }; + + pub const LightningError = struct { + code: enum(u8) { + uninitialized, // wallet uninitialized + //init_failed, TODO: when .lightning_init_wallet results in an error + not_ready, // in a startup mode + locked, // wallet locked + }, + }; + + /// https://lightning.engineering/api-docs/api/lnd/wallet-unlocker/gen-seed + pub const LightningGenSeed = struct { + // TODO: support passphrase + //passphrase: ?[]const u8, + }; + + /// https://lightning.engineering/api-docs/api/lnd/wallet-unlocker/init-wallet + pub const LightningInitWallet = struct { + mnemonic: []const []const u8, // 24 words + // TODO: support passphrase + //passphrase: ?[]const u8, + + // TODO: support extra fields for restoring an existing wallet, like recovery_window + }; + pub const SysupdatesChan = enum { stable, // master branch in sysupdates edge, // dev branch in sysupdates @@ -226,6 +279,8 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !ParsedMessage { const len = try reader.readIntLittle(u64); if (len == 0) { return switch (tag) { + .lightning_get_ctrlconn => .{ .value = .lightning_get_ctrlconn }, + .lightning_reset => .{ .value = .lightning_reset }, .ping => .{ .value = .{ .ping = {} } }, .pong => .{ .value = .{ .pong = {} } }, .poweroff => .{ .value = .{ .poweroff = {} } }, @@ -235,7 +290,14 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !ParsedMessage { }; } switch (tag) { - .ping, .pong, .poweroff, .standby, .wakeup => unreachable, // handled above + .lightning_get_ctrlconn, + .lightning_reset, + .ping, + .pong, + .poweroff, + .standby, + .wakeup, + => unreachable, // handled above inline else => |t| { var bytes = try allocator.alloc(u8, len); defer allocator.free(bytes); @@ -271,6 +333,13 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()), .onchain_report => try json.stringify(msg.onchain_report, .{}, data.writer()), .lightning_report => try json.stringify(msg.lightning_report, .{}, data.writer()), + .lightning_error => try json.stringify(msg.lightning_error, .{}, data.writer()), + .lightning_genseed => try json.stringify(msg.lightning_genseed, .{}, data.writer()), + .lightning_genseed_result => try json.stringify(msg.lightning_genseed_result, .{}, data.writer()), + .lightning_init_wallet => try json.stringify(msg.lightning_init_wallet, .{}, data.writer()), + .lightning_get_ctrlconn => {}, // zero length payload + .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()), .settings => try json.stringify(msg.settings, .{}, data.writer()), } @@ -364,6 +433,8 @@ test "write/read void tags" { defer buf.deinit(); const msg = [_]Message{ + Message.lightning_get_ctrlconn, + Message.lightning_reset, Message.ping, Message.pong, Message.poweroff, diff --git a/src/lndhttp.zig b/src/lndhttp.zig index b198d0f..a6c435e 100644 --- a/src/lndhttp.zig +++ b/src/lndhttp.zig @@ -1,6 +1,8 @@ //! lnd lightning HTTP client and utility functions. const std = @import("std"); +const base64enc = std.base64.standard.Encoder; + const types = @import("types.zig"); /// safe for concurrent use as long as Client.allocator is. @@ -10,30 +12,43 @@ pub const Client = struct { port: u16 = 10010, apibase: []const u8, // https://localhost:10010 macaroon: struct { - readonly: []const u8, + readonly: ?[]const u8, admin: ?[]const u8, }, httpClient: std.http.Client, + pub const Error = error{ + LndHttpMissingMacaroon, + LndHttpBadStatusCode, + LndPayloadWriteFail, + }; + pub const ApiMethod = enum { + // no auth methods + genseed, // generate a new wallet seed; non-committing + walletstatus, // server/wallet status + initwallet, // commit a seed and create a node wallet + unlockwallet, // required after successfull initwallet + // read-only feereport, // fees of all active channels getinfo, // general host node info getnetworkinfo, // visible graph info listchannels, // active channels pendingchannels, // pending open/close channels walletbalance, // onchain balance - walletstatus, // server/wallet status // fwdinghistory, getchaninfo, getnodeinfo - // genseed, initwallet, unlockwallet // watchtower: getinfo, stats, list, add, remove fn apipath(self: @This()) []const u8 { return switch (self) { .feereport => "v1/fees", + .genseed => "v1/genseed", .getinfo => "v1/getinfo", .getnetworkinfo => "v1/graph/info", + .initwallet => "v1/initwallet", .listchannels => "v1/channels", .pendingchannels => "v1/channels/pending", + .unlockwallet => "v1/unlockwallet", .walletbalance => "v1/balance/blockchain", .walletstatus => "v1/state", }; @@ -42,6 +57,20 @@ pub const Client = struct { pub fn MethodArgs(comptime m: ApiMethod) type { return switch (m) { + .initwallet => struct { + unlock_password: []const u8, // min 8 bytes + mnemonic: []const []const u8, // 24 words + passphrase: ?[]const u8 = null, + // TODO: restore an existing wallet: + //recovery_window: i32 = 0, // applies to each branch of BIP44 derivation path + //channel_backups + }, + .unlockwallet => struct { + unlock_password: []const u8, // from initwallet + // TODO: restore an existing wallet: + //recovery_window: i32 = 0, // applies to each branch of BIP44 derivation path + //channel_backups + }, .listchannels => struct { status: ?enum { active, inactive } = null, advert: ?enum { public, private } = null, @@ -55,10 +84,13 @@ pub const Client = struct { pub fn ResultValue(comptime m: ApiMethod) type { return switch (m) { .feereport => FeeReport, + .genseed => GeneratedSeed, .getinfo => LndInfo, .getnetworkinfo => NetworkInfo, + .initwallet => InitedWallet, .listchannels => ChannelsList, .pendingchannels => PendingList, + .unlockwallet => struct {}, .walletbalance => WalletBalance, .walletstatus => WalletStatus, }; @@ -69,7 +101,7 @@ pub const Client = struct { hostname: []const u8 = "localhost", // must be present in tlscert_path SANs port: u16 = 10010, // HTTP API port tlscert_path: []const u8, // must contain the hostname in SANs - macaroon_ro_path: []const u8, // readonly macaroon path + macaroon_ro_path: ?[]const u8 = null, // readonly macaroon path macaroon_admin_path: ?[]const u8 = null, // required only for requests mutating lnd state }; @@ -77,13 +109,14 @@ pub const Client = struct { /// must deinit when done. pub fn init(opt: InitOpt) !Client { var ca = std.crypto.Certificate.Bundle{}; // deinit'ed by http.Client.deinit - errdefer ca.deinit(opt.allocator); try ca.addCertsFromFilePathAbsolute(opt.allocator, opt.tlscert_path); + errdefer ca.deinit(opt.allocator); + const mac_ro: ?[]const u8 = if (opt.macaroon_ro_path) |p| try readMacaroonOrNull(opt.allocator, p) else null; + errdefer if (mac_ro) |v| opt.allocator.free(v); + const mac_admin: ?[]const u8 = if (opt.macaroon_admin_path) |p| try readMacaroonOrNull(opt.allocator, p) else null; + errdefer if (mac_admin) |v| opt.allocator.free(v); const apibase = try std.fmt.allocPrint(opt.allocator, "https://{s}:{d}", .{ opt.hostname, opt.port }); errdefer opt.allocator.free(apibase); - const mac_ro = try readMacaroon(opt.allocator, opt.macaroon_ro_path); - errdefer opt.allocator.free(mac_ro); - const mac_admin = if (opt.macaroon_admin_path) |p| try readMacaroon(opt.allocator, p) else null; return .{ .allocator = opt.allocator, .apibase = apibase, @@ -99,10 +132,8 @@ pub const Client = struct { pub fn deinit(self: *Client) void { self.httpClient.deinit(); self.allocator.free(self.apibase); - self.allocator.free(self.macaroon.readonly); - if (self.macaroon.admin) |a| { - self.allocator.free(a); - } + if (self.macaroon.readonly) |ro| self.allocator.free(ro); + if (self.macaroon.admin) |a| self.allocator.free(a); } pub fn Result(comptime m: ApiMethod) type { @@ -116,14 +147,21 @@ pub const Client = struct { const opt = std.http.Client.Options{ .handle_redirects = false }; // no redirects in REST API var req = try self.httpClient.request(reqinfo.httpmethod, reqinfo.url, reqinfo.headers, opt); defer req.deinit(); + if (reqinfo.payload) |p| { + req.transfer_encoding = .{ .content_length = p.len }; + } + try req.start(); if (reqinfo.payload) |p| { - try req.writer().writeAll(p); + req.writer().writeAll(p) catch return Error.LndPayloadWriteFail; try req.finish(); } try req.wait(); if (req.response.status.class() != .success) { - return error.LndHttpBadStatusCode; + // a structured error reporting in lnd is in a less than desirable state. + // https://github.com/lightningnetwork/lnd/issues/5586 + // TODO: return a more detailed error when the upstream improves. + return Error.LndHttpBadStatusCode; } if (@TypeOf(Result(apimethod)) == void) { return; // void response; need no json parsing @@ -153,12 +191,61 @@ pub const Client = struct { errdefer reqinfo.deinit(); const arena = reqinfo.arena.allocator(); reqinfo.value = switch (apimethod) { - .feereport, .getinfo, .getnetworkinfo, .pendingchannels, .walletbalance, .walletstatus => |m| .{ + .genseed, .walletstatus => |m| .{ + .httpmethod = .GET, + .url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })), + .headers = std.http.Headers{ .allocator = arena }, + .payload = null, + }, + .initwallet => |m| blk: { + const payload = p: { + var params: struct { + wallet_password: []const u8, // base64 + cipher_seed_mnemonic: []const []const u8, + aezeed_passphrase: ?[]const u8 = null, // base64 + } = .{ + .wallet_password = try base64EncodeAlloc(arena, args.unlock_password), + .cipher_seed_mnemonic = args.mnemonic, + .aezeed_passphrase = if (args.passphrase) |p| try base64EncodeAlloc(arena, p) else null, + }; + var buf = std.ArrayList(u8).init(arena); + try std.json.stringify(params, .{ .emit_null_optional_fields = false }, buf.writer()); + break :p try buf.toOwnedSlice(); + }; + break :blk .{ + .httpmethod = .POST, + .url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })), + .headers = std.http.Headers{ .allocator = arena }, + .payload = payload, + }; + }, + .unlockwallet => |m| blk: { + const payload = p: { + var params: struct { + wallet_password: []const u8, // base64 + } = .{ + .wallet_password = try base64EncodeAlloc(arena, args.unlock_password), + }; + var buf = std.ArrayList(u8).init(arena); + try std.json.stringify(params, .{ .emit_null_optional_fields = false }, buf.writer()); + break :p try buf.toOwnedSlice(); + }; + break :blk .{ + .httpmethod = .POST, + .url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })), + .headers = std.http.Headers{ .allocator = arena }, + .payload = payload, + }; + }, + .feereport, .getinfo, .getnetworkinfo, .pendingchannels, .walletbalance => |m| .{ .httpmethod = .GET, .url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })), .headers = blk: { + if (self.macaroon.readonly == null) { + return Error.LndHttpMissingMacaroon; + } var h = std.http.Headers{ .allocator = arena }; - try h.append(authHeaderName, self.macaroon.readonly); + try h.append(authHeaderName, self.macaroon.readonly.?); break :blk h; }, .payload = null, @@ -184,8 +271,11 @@ pub const Client = struct { break :blk try std.Uri.parse(buf.items); // uri point to the original buf }, .headers = blk: { + if (self.macaroon.readonly == null) { + return Error.LndHttpMissingMacaroon; + } var h = std.http.Headers{ .allocator = arena }; - try h.append(authHeaderName, self.macaroon.readonly); + try h.append(authHeaderName, self.macaroon.readonly.?); break :blk h; }, .payload = null, @@ -194,13 +284,23 @@ pub const Client = struct { return reqinfo; } + /// returns null if file not found. /// callers own returned value. - fn readMacaroon(gpa: std.mem.Allocator, path: []const u8) ![]const u8 { - const file = try std.fs.openFileAbsolute(path, .{ .mode = .read_only }); + fn readMacaroonOrNull(gpa: std.mem.Allocator, path: []const u8) !?[]const u8 { + const file = std.fs.openFileAbsolute(path, .{ .mode = .read_only }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; defer file.close(); - const cont = try file.readToEndAlloc(gpa, 1024); - defer gpa.free(cont); - return std.fmt.allocPrint(gpa, "{}", .{std.fmt.fmtSliceHexLower(cont)}); + const raw = try file.readToEndAlloc(gpa, 1024); + defer gpa.free(raw); + const hex = try std.fmt.allocPrint(gpa, "{}", .{std.fmt.fmtSliceHexLower(raw)}); + return hex; + } + + fn base64EncodeAlloc(gpa: std.mem.Allocator, v: []const u8) ![]const u8 { + var buf = try gpa.alloc(u8, base64enc.calcSize(v.len)); + return base64enc.encode(buf, v); // always returns a slice of buf.len } }; @@ -326,3 +426,13 @@ pub const WalletStatus = struct { WAITING_TO_START = 255, }, }; + +/// https://lightning.engineering/api-docs/api/lnd/wallet-unlocker/gen-seed +pub const GeneratedSeed = struct { + cipher_seed_mnemonic: []const []const u8, // 24 words aezeed +}; + +/// https://lightning.engineering/api-docs/api/lnd/wallet-unlocker/init-wallet +pub const InitedWallet = struct { + admin_macaroon: []const u8, // base64? +}; diff --git a/src/nd/Config.zig b/src/nd/Config.zig index f25f419..2f85cef 100644 --- a/src/nd/Config.zig +++ b/src/nd/Config.zig @@ -11,9 +11,25 @@ const SYSUPDATES_CRON_SCRIPT_PATH = "/etc/cron.hourly/sysupdate"; const SYSUPDATES_RUN_SCRIPT_NAME = "update.sh"; const SYSUPDATES_RUN_SCRIPT_PATH = "/ssd/sysupdates/" ++ SYSUPDATES_RUN_SCRIPT_NAME; +/// must be the same as https://git.qcode.ch/nakamochi/sysupdates/src/branch/master/lnd +pub const LND_OS_USER = "lnd"; +pub const LND_DATA_DIR = "/ssd/lnd/data"; +pub const LND_LOG_DIR = "/ssd/lnd/logs"; +pub const LND_HOMEDIR = "/home/lnd"; +pub const LND_CONF_PATH = LND_HOMEDIR ++ "/lnd.mainnet.conf"; +pub const LND_TLSKEY_PATH = LND_HOMEDIR ++ "/.lnd/tls.key"; +pub const LND_TLSCERT_PATH = LND_HOMEDIR ++ "/.lnd/tls.cert"; +pub const LND_WALLETUNLOCK_PATH = LND_HOMEDIR ++ "/walletunlock.txt"; +pub const LND_MACAROON_RO_PATH = LND_DATA_DIR ++ "/chain/bitcoin/mainnet/readonly.macaroon"; +pub const LND_MACAROON_ADMIN_PATH = LND_DATA_DIR ++ "/chain/bitcoin/mainnet/admin.macaroon"; + +pub const BITCOIND_CONFIG_PATH = "/home/bitcoind/mainnet.conf"; +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 +static: StaticData, mu: std.Thread.RwLock = .{}, data: Data, @@ -25,6 +41,12 @@ pub const Data = struct { sysrunscript: []const u8, }; +/// static data is always interred at init and never changes. +pub const StaticData = struct { + lnd_tor_hostname: ?[]const u8, + bitcoind_rpc_pass: ?[]const u8, +}; + /// enums must match git branches in https://git.qcode.ch/nakamochi/sysupdates. pub const SysupdatesChannel = enum { master, // stable @@ -43,8 +65,9 @@ pub fn init(allocator: std.mem.Allocator, confpath: []const u8) !Config { } return .{ .arena = arena, - .data = try initData(arena.allocator(), confpath), .confpath = confpath, + .data = try initData(arena.allocator(), confpath), + .static = inferStaticData(arena.allocator()), }; } @@ -92,6 +115,42 @@ fn inferSysupdatesChannel(cron_script_path: []const u8) SysupdatesChannel { return .master; } +fn inferStaticData(allocator: std.mem.Allocator) StaticData { + return .{ + .lnd_tor_hostname = inferLndTorHostname(allocator) catch null, + .bitcoind_rpc_pass = inferBitcoindRpcPass(allocator) catch null, + }; +} + +fn inferLndTorHostname(allocator: std.mem.Allocator) ![]const u8 { + var raw = try std.fs.cwd().readFileAlloc(allocator, TOR_DATA_DIR ++ "/lnd/hostname", 1024); + const hostname = std.mem.trim(u8, raw, &std.ascii.whitespace); + logger.info("inferred lnd tor hostname: [{s}]", .{hostname}); + return hostname; +} + +fn inferBitcoindRpcPass(allocator: std.mem.Allocator) ![]const u8 { + // a hack to recover bitcoind rpc password from an original conf template. + // the password was placed on a separate comment line, preceding another comment + // line containing "rpcauth.py". + // TODO: get rid of the hack; do something more robust + var conf = try std.fs.cwd().readFileAlloc(allocator, BITCOIND_CONFIG_PATH, 1024 * 1024); + var it = std.mem.tokenizeScalar(u8, conf, '\n'); + var next_is_pass = false; + while (it.next()) |line| { + if (next_is_pass) { + if (!std.mem.startsWith(u8, line, "#")) { + return error.UninferrableBitcoindRpcPass; + } + return std.mem.trim(u8, line[1..], &std.ascii.whitespace); + } + if (std.mem.startsWith(u8, line, "#") and std.mem.indexOf(u8, line, "rpcauth.py") != null) { + next_is_pass = true; + } + } + return error.UninferrableBitcoindRpcPass; +} + /// calls F while holding a readonly lock and passes on F's result as is. pub fn safeReadOnly(self: *Config, comptime F: anytype) @typeInfo(@TypeOf(F)).Fn.return_type.? { self.mu.lockShared(); @@ -171,6 +230,131 @@ fn runSysupdates(allocator: std.mem.Allocator, scriptpath: []const u8) !void { } } +/// waits until lnd admin macaroon is readable and returns an lndconnect URL. +/// caller owns returned value. +pub fn lndConnectWaitMacaroonFile(self: Config, allocator: std.mem.Allocator, typ: enum { tor_rpc, tor_http }) ![]const u8 { + var macaroon: []const u8 = undefined; + while (true) { + macaroon = std.fs.cwd().readFileAlloc(allocator, LND_MACAROON_ADMIN_PATH, 2048) catch |err| { + switch (err) { + error.FileNotFound => { + std.atomic.spinLoopHint(); + std.time.sleep(1 * std.time.ns_per_s); + continue; + }, + else => return err, + } + }; + break; + } + defer allocator.free(macaroon); + + const base64enc = std.base64.url_safe_no_pad.Encoder; + var buf = try allocator.alloc(u8, base64enc.calcSize(macaroon.len)); + defer allocator.free(buf); + const macaroon_b64 = base64enc.encode(buf, macaroon); + const port: u16 = switch (typ) { + .tor_rpc => 10009, + .tor_http => 10010, + }; + return std.fmt.allocPrint(allocator, "lndconnect://{[host]s}:{[port]d}?macaroon={[macaroon]s}", .{ + .host = self.static.lnd_tor_hostname orelse ".onion", + .port = port, + .macaroon = macaroon_b64, + }); +} + +/// generates a random bytes sequence of the given size, dumps it into `LND_WALLETUNLOCK_PATH` +/// file, changing the ownership to `LND_OS_USER`, as well as into the buf in hex encoding. +/// the buffer must be at least twice the size. +/// returns the bytes printed to outbuf. +pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize) ![]const u8 { + const filepath = LND_WALLETUNLOCK_PATH; + const lnduser = try std.process.getUserInfo(LND_OS_USER); + + const allocator = self.arena.child_allocator; + const opt = .{ .mode = 0o400 }; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), filepath, opt); + defer file.destroy(); // frees resources; does NOT delete the file + + var raw_unlock_pwd: [raw_size]u8 = undefined; + std.crypto.random.bytes(&raw_unlock_pwd); + 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); + + return hex; +} + +/// creates or overwrites existing lnd config file at `LND_CONF_PATH`. +pub fn genLndConfig(self: Config, opt: struct { autounlock: bool }) !void { + const confpath = LND_CONF_PATH; + const lnduser = try std.process.getUserInfo(LND_OS_USER); + + const allocator = self.arena.child_allocator; + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), confpath, .{ .mode = 0o400 }); + defer file.destroy(); // frees resources; does NOT delete the file + const w = file.writer(); + + // main app settings + try w.writeAll("[Application Options]\n"); + try w.writeAll("debuglevel=info\n"); + try w.writeAll("maxpendingchannels=10\n"); + try w.writeAll("maxlogfiles=3\n"); + try w.writeAll("listen=[::]:9735\n"); // or 0.0.0.0:9735 + try w.writeAll("rpclisten=0.0.0.0:10009\n"); + try w.writeAll("restlisten=0.0.0.0:10010\n"); // TODO: replace with 127.0.0.1 and no-rest-tls=true? + try std.fmt.format(w, "alias={s}\n", .{"nakamochi"}); // TODO: make alias configurable + try std.fmt.format(w, "datadir={s}\n", .{LND_DATA_DIR}); + try std.fmt.format(w, "logdir={s}\n", .{LND_LOG_DIR}); + if (self.static.lnd_tor_hostname) |torhost| { + try std.fmt.format(w, "tlsextradomain={s}\n", .{torhost}); + try std.fmt.format(w, "externalhosts={s}\n", .{torhost}); + } + if (opt.autounlock) { + try std.fmt.format(w, "wallet-unlock-password-file={s}\n", .{LND_WALLETUNLOCK_PATH}); + } + + // bitcoin chain settings + try w.writeAll("\n[bitcoin]\n"); + try std.fmt.format(w, "bitcoin.chaindir={s}/chain/mainnet\n", .{LND_DATA_DIR}); + try w.writeAll("bitcoin.active=true\n"); + try w.writeAll("bitcoin.mainnet=True\n"); + try w.writeAll("bitcoin.testnet=False\n"); + try w.writeAll("bitcoin.regtest=False\n"); + try w.writeAll("bitcoin.simnet=False\n"); + try w.writeAll("bitcoin.node=bitcoind\n"); + try w.writeAll("\n[bitcoind]\n"); + try w.writeAll("bitcoind.zmqpubrawblock=tcp://127.0.0.1:8331\n"); + try w.writeAll("bitcoind.zmqpubrawtx=tcp://127.0.0.1:8330\n"); + try w.writeAll("bitcoind.rpchost=127.0.0.1\n"); + try w.writeAll("bitcoind.rpcuser=rpc\n"); + if (self.static.bitcoind_rpc_pass) |rpcpass| { + try std.fmt.format(w, "bitcoind.rpcpass={s}\n", .{rpcpass}); + } else { + return error.GenLndConfigNoBitcoindRpcPass; + } + + // other settings + try w.writeAll("\n[autopilot]\n"); + try w.writeAll("autopilot.active=false\n"); + try w.writeAll("\n[tor]\n"); + try w.writeAll("tor.active=true\n"); + try w.writeAll("tor.skip-proxy-for-clearnet-targets=true\n"); + + // persist the file in the correct location. + try file.finish(); + + // change file ownership to that of the lnd system user. + const f = try std.fs.cwd().openFile(confpath, .{}); + defer f.close(); + try f.chown(lnduser.uid, lnduser.gid); +} + test "init existing" { const t = std.testing; const tt = @import("../test.zig"); @@ -220,6 +404,7 @@ test "dump" { .syscronscript = "cronscript.sh", .sysrunscript = "runscript.sh", }, + .static = undefined, }; // purposefully skip conf.deinit() - expecting no leaking allocations in conf.dump. try conf.dump(); @@ -252,6 +437,7 @@ test "switch sysupdates and infer" { .syscronscript = cronscript, .sysrunscript = SYSUPDATES_RUN_SCRIPT_PATH, }, + .static = undefined, }; // purposefully skip conf.deinit() - expecting no leaking allocations. @@ -290,6 +476,7 @@ test "switch sysupdates with .run=true" { .syscronscript = try tmp.join(&.{"cronscript.sh"}), .sysrunscript = try tmp.join(&.{runscript}), }, + .static = undefined, }; defer conf.deinit(); diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 8a666e9..155d6cf 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -41,6 +41,7 @@ state: enum { running, standby, poweroff, + wallet_reset, }, main_thread: ?std.Thread = null, @@ -64,14 +65,56 @@ onchain_report_interval: u64 = 1 * time.ns_per_min, want_lnd_report: bool, lnd_timer: time.Timer, lnd_report_interval: u64 = 1 * time.ns_per_min, +lnd_tls_reset_count: usize = 0, /// 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: []SysService = &.{}, +services: struct { + list: []SysService, + + fn stopWait(self: @This(), name: []const u8) !void { + for (self.list) |*sv| { + if (std.mem.eql(u8, sv.name, name)) { + return sv.stopWait(); + } + } + return error.NoSuchServiceToStop; + } + + fn start(self: @This(), name: []const u8) !void { + for (self.list) |*sv| { + if (std.mem.eql(u8, sv.name, name)) { + return sv.start(); + } + } + return error.NoSuchServiceToStart; + } + + fn deinit(self: @This(), allocator: std.mem.Allocator) void { + for (self.list) |*sv| { + sv.deinit(); + } + allocator.free(self.list); + } +} = .{ .list = &.{} }, const Daemon = @This(); +const Error = error{ + InvalidState, + WalletResetActive, + PoweroffActive, + AlreadyStarted, + ConnectWifiEmptySSID, + MakeWalletUnlockFileFail, + LndServiceStopFail, + ResetLndFail, + GenLndConfigFail, + InitLndWallet, + UnlockLndWallet, +}; + const InitOpt = struct { allocator: std.mem.Allocator, confpath: []const u8, @@ -91,8 +134,8 @@ pub fn init(opt: InitOpt) !Daemon { } // 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, "lnd", .{ .stop_wait_sec = 600 })); - try svlist.append(SysService.init(opt.allocator, "bitcoind", .{ .stop_wait_sec = 600 })); + 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 })); const conf = try Config.init(opt.allocator, opt.confpath); errdefer conf.deinit(); @@ -103,7 +146,7 @@ pub fn init(opt: InitOpt) !Daemon { .uiwriter = opt.uiw, .wpa_ctrl = try types.WpaControl.open(opt.wpa), .state = .stopped, - .services = try svlist.toOwnedSlice(), + .services = .{ .list = try svlist.toOwnedSlice() }, // send persisted settings immediately on start .want_settings = true, // send a network report right at start without wifi scan to make it faster. @@ -122,12 +165,9 @@ pub fn init(opt: InitOpt) !Daemon { /// releases all associated resources. /// the daemon must be stop'ed and wait'ed before deiniting. pub fn deinit(self: *Daemon) void { - defer self.conf.deinit(); self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); - for (self.services) |*sv| { - sv.deinit(); - } - self.allocator.free(self.services); + self.services.deinit(self.allocator); + self.conf.deinit(); } /// start launches daemon threads and returns immediately. @@ -138,8 +178,8 @@ pub fn start(self: *Daemon) !void { defer self.mu.unlock(); switch (self.state) { .stopped => {}, // continue - .poweroff => return error.InPoweroffState, - else => return error.AlreadyStarted, + .poweroff => return Error.PoweroffActive, + else => return Error.AlreadyStarted, } try self.wpa_ctrl.attach(); @@ -193,7 +233,8 @@ fn standby(self: *Daemon) !void { defer self.mu.unlock(); switch (self.state) { .standby => {}, - .stopped, .poweroff => return error.InvalidState, + .stopped, .poweroff => return Error.InvalidState, + .wallet_reset => return Error.WalletResetActive, .running => { try screen.backlight(.off); self.state = .standby; @@ -206,8 +247,8 @@ fn wakeup(self: *Daemon) !void { self.mu.lock(); defer self.mu.unlock(); switch (self.state) { - .running => {}, - .stopped, .poweroff => return error.InvalidState, + .running, .wallet_reset => {}, + .stopped, .poweroff => return Error.InvalidState, .standby => { try screen.backlight(.on); self.state = .running; @@ -225,7 +266,8 @@ fn beginPoweroff(self: *Daemon) !void { defer self.mu.unlock(); switch (self.state) { .poweroff => {}, // already in poweroff mode - .stopped => return error.InvalidState, + .stopped => return Error.InvalidState, + .wallet_reset => return Error.WalletResetActive, .running, .standby => { self.poweroff_thread = try std.Thread.spawn(.{}, poweroffThread, .{self}); self.state = .poweroff; @@ -250,13 +292,13 @@ fn poweroffThread(self: *Daemon) void { }; // initiate shutdown of all services concurrently. - for (self.services) |*sv| { + for (self.services.list) |*sv| { sv.stop() catch |err| logger.err("sv stop '{s}': {any}", .{ sv.name, err }); } self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err}); // wait each service until stopped or error. - for (self.services) |*sv| { + for (self.services.list) |*sv| { _ = sv.stopWait() catch {}; logger.info("{s} sv is now stopped; err={any}", .{ sv.name, sv.lastStopError() }); self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err}); @@ -311,6 +353,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { self.want_settings = !ok; } + // network stats self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); if (self.want_wifi_scan) { if (self.startWifiScan()) { @@ -327,6 +370,7 @@ fn mainThreadLoopCycle(self: *Daemon) !void { } } + // onchain bitcoin stats if (self.want_onchain_report or self.bitcoin_timer.read() > self.onchain_report_interval) { if (self.sendOnchainReport()) { self.bitcoin_timer.reset(); @@ -335,12 +379,17 @@ fn mainThreadLoopCycle(self: *Daemon) !void { logger.err("sendOnchainReport: {any}", .{err}); } } - if (self.want_lnd_report or self.lnd_timer.read() > self.lnd_report_interval) { - if (self.sendLightningReport()) { - self.lnd_timer.reset(); - self.want_lnd_report = false; - } else |err| { - logger.err("sendLightningReport: {any}", .{err}); + + // lightning stats + if (self.state != .wallet_reset) { + if (self.want_lnd_report or self.lnd_timer.read() > self.lnd_report_interval) { + if (self.sendLightningReport()) { + self.lnd_timer.reset(); + self.want_lnd_report = false; + } else |err| { + logger.info("sendLightningReport: {!}", .{err}); + self.processLndReportError(err) catch |err2| logger.err("processLndReportError: {!}", .{err2}); + } } } } @@ -363,7 +412,7 @@ fn commThreadLoop(self: *Daemon) void { } switch (self.state) { .stopped, .poweroff => break :loop, - .running, .standby => { + .running, .standby, .wallet_reset => { logger.err("commThreadLoop: {any}", .{err}); if (err == error.EndOfStream) { // pointless to continue running if comms I/O is broken. @@ -407,6 +456,27 @@ fn commThreadLoop(self: *Daemon) void { // TODO: send err back to ngui }; }, + .lightning_genseed => { + self.generateWalletSeed() catch |err| { + logger.err("generateWalletSeed: {!}", .{err}); + // TODO: send err back to ngui + }; + }, + .lightning_init_wallet => |req| { + self.initWallet(req) catch |err| { + logger.err("initWallet: {!}", .{err}); + // TODO: send err back to ngui + }; + }, + .lightning_get_ctrlconn => { + self.sendLightningPairingConn() catch |err| { + logger.err("sendLightningPairingConn: {!}", .{err}); + // TODO: send err back to ngui + }; + }, + .lightning_reset => { + self.resetLndNode() catch |err| logger.err("resetLndNode: {!}", .{err}); + }, else => |v| logger.warn("unhandled msg tag {s}", .{@tagName(v)}), } @@ -420,9 +490,9 @@ fn commThreadLoop(self: *Daemon) void { /// sends poweroff progress to uiwriter in comm.Message.PoweroffProgress format. fn sendPoweroffReport(self: *Daemon) !void { - var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len); + var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.list.len); defer self.allocator.free(svstat); - for (self.services, svstat) |*sv, *stat| { + for (self.services.list, svstat) |*sv, *stat| { stat.* = .{ .name = sv.name, .stopped = sv.status() == .stopped, @@ -490,7 +560,7 @@ fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void { /// initiates wifi connection procedure in a separate thread fn startConnectWifi(self: *Daemon, ssid: []const u8, password: []const u8) !void { if (ssid.len == 0) { - return error.ConnectWifiEmptySSID; + return Error.ConnectWifiEmptySSID; } const ssid_copy = try self.allocator.dupe(u8, ssid); const pwd_copy = try self.allocator.dupe(u8, password); @@ -566,6 +636,7 @@ fn readWPACtrlMsg(self: *Daemon) !void { } } +/// callers must hold self.mu due to self.state read access via fetchOnchainStats. fn sendOnchainReport(self: *Daemon) !void { const stats = self.fetchOnchainStats() catch |err| { switch (err) { @@ -631,6 +702,7 @@ const OnchainStats = struct { balance: ?lndhttp.Client.Result(.walletbalance), }; +/// call site must hold self.mu due to self.state read access. /// callers own returned value. fn fetchOnchainStats(self: *Daemon) !OnchainStats { var client = bitcoindrpc.Client{ @@ -642,10 +714,14 @@ fn fetchOnchainStats(self: *Daemon) !OnchainStats { const mempool = try client.call(.getmempoolinfo, {}); const balance: ?lndhttp.Client.Result(.walletbalance) = blk: { // lndhttp.WalletBalance + if (self.state == .wallet_reset) { + break :blk null; + } var lndc = lndhttp.Client.init(.{ .allocator = self.allocator, - .tlscert_path = "/home/lnd/.lnd/tls.cert", - .macaroon_ro_path = "/ssd/lnd/data/chain/bitcoin/mainnet/readonly.macaroon", + .tlscert_path = Config.LND_TLSCERT_PATH, + .macaroon_ro_path = Config.LND_MACAROON_RO_PATH, + .macaroon_admin_path = Config.LND_MACAROON_ADMIN_PATH, }) catch break :blk null; defer lndc.deinit(); const res = lndc.call(.walletbalance, {}) catch break :blk null; @@ -662,8 +738,9 @@ fn fetchOnchainStats(self: *Daemon) !OnchainStats { fn sendLightningReport(self: *Daemon) !void { var client = try lndhttp.Client.init(.{ .allocator = self.allocator, - .tlscert_path = "/home/lnd/.lnd/tls.cert", - .macaroon_ro_path = "/ssd/lnd/data/chain/bitcoin/mainnet/readonly.macaroon", + .tlscert_path = Config.LND_TLSCERT_PATH, + .macaroon_ro_path = Config.LND_MACAROON_RO_PATH, + .macaroon_admin_path = Config.LND_MACAROON_ADMIN_PATH, }); defer client.deinit(); @@ -801,6 +878,238 @@ fn sendLightningReport(self: *Daemon) !void { try comm.write(self.allocator, self.uiwriter, .{ .lightning_report = lndrep }); } +/// evaluates any error returned from `sendLightningReport`. +/// callers must hold self.mu. +fn processLndReportError(self: *Daemon, err: anyerror) !void { + const msg_starting: comm.Message = .{ .lightning_error = .{ .code = .not_ready } }; + const msg_locked: comm.Message = .{ .lightning_error = .{ .code = .locked } }; + const msg_uninitialized: comm.Message = .{ .lightning_error = .{ .code = .uninitialized } }; + + switch (err) { + error.ConnectionRefused, + error.FileNotFound, // tls cert file missing, not re-generated by lnd yet + => return comm.write(self.allocator, self.uiwriter, msg_starting), + // old tls cert, refused by our http client + std.http.Client.ConnectUnproxiedError.TlsInitializationFailed => { + try self.resetLndTlsUnguarded(); + return error.LndReportRetryLater; + }, + else => {}, // continue + } + + // checking wallet status requires no macaroon auth + var client = try lndhttp.Client.init(.{ .allocator = self.allocator, .tlscert_path = Config.LND_TLSCERT_PATH }); + defer client.deinit(); + const status = client.call(.walletstatus, {}) catch |err2| { + switch (err2) { + error.TlsInitializationFailed => { + try self.resetLndTlsUnguarded(); + return error.LndReportRetryLater; + }, + else => return err2, + } + }; + defer status.deinit(); + logger.info("processLndReportError: lnd wallet state: {s}", .{@tagName(status.value.state)}); + return switch (status.value.state) { + .NON_EXISTING => { + try comm.write(self.allocator, self.uiwriter, msg_uninitialized); + self.lnd_timer.reset(); + self.want_lnd_report = false; + }, + .LOCKED => { + try comm.write(self.allocator, self.uiwriter, msg_locked); + self.lnd_timer.reset(); + self.want_lnd_report = false; + }, + .UNLOCKED, .RPC_ACTIVE, .WAITING_TO_START => comm.write(self.allocator, self.uiwriter, msg_starting), + // active server indicates the lnd is ready to accept calls. so, the error + // must have been due to factors other than unoperational lnd state. + .SERVER_ACTIVE => err, + }; +} + +fn sendLightningPairingConn(self: *Daemon) !void { + const tor_rpc = try self.conf.lndConnectWaitMacaroonFile(self.allocator, .tor_rpc); + defer self.allocator.free(tor_rpc); + const tor_http = try self.conf.lndConnectWaitMacaroonFile(self.allocator, .tor_http); + defer self.allocator.free(tor_http); + var conn: comm.Message.LightningCtrlConn = &.{ + .{ .url = tor_rpc, .typ = .lnd_rpc, .perm = .admin }, + .{ .url = tor_http, .typ = .lnd_http, .perm = .admin }, + }; + try comm.write(self.allocator, self.uiwriter, .{ .lightning_ctrlconn = conn }); +} + +/// a non-committal seed generator. can be called any number of times. +fn generateWalletSeed(self: *Daemon) !void { + // genseed needs no auth + var client = try lndhttp.Client.init(.{ .allocator = self.allocator, .tlscert_path = Config.LND_TLSCERT_PATH }); + defer client.deinit(); + const res = try client.call(.genseed, {}); + defer res.deinit(); + const msg = comm.Message{ .lightning_genseed_result = res.value.cipher_seed_mnemonic }; + return comm.write(self.allocator, self.uiwriter, msg); +} + +/// commit req.mnemonic as the new lightning wallet. +/// this also creates a new unlock password, placing it at `Config.LND_WALLETUNLOCK_PATH`, +/// and finally generates a new lnd config file to persist the changes. +fn initWallet(self: *Daemon, req: comm.Message.LightningInitWallet) !void { + self.mu.lock(); + switch (self.state) { + .stopped, .poweroff, .wallet_reset => { + defer self.mu.unlock(); + switch (self.state) { + .poweroff => return Error.PoweroffActive, + .wallet_reset => return Error.WalletResetActive, + else => return Error.InvalidState, + } + }, + // proceed only when in one of the following states + .standby => screen.backlight(.on) catch |err| logger.err("initWallet: backlight on: {!}", .{err}), + .running => {}, + } + defer { + self.mu.lock(); + self.state = .running; + self.mu.unlock(); + } + self.state = .wallet_reset; + self.mu.unlock(); + + // generate a new wallet unlock password; used together with seed committal below. + var buf: [128]u8 = undefined; + const unlock_pwd = self.conf.makeWalletUnlockFile(&buf, 8) catch |err| { + logger.err("makeWalletUnlockFile: {!}", .{err}); + return Error.MakeWalletUnlockFileFail; + }; + + // commit the seed: initwallet needs no auth + logger.info("initwallet: committing new seed and an unlock password", .{}); + var client = try lndhttp.Client.init(.{ .allocator = self.allocator, .tlscert_path = Config.LND_TLSCERT_PATH }); + defer client.deinit(); + const res = client.call(.initwallet, .{ .unlock_password = unlock_pwd, .mnemonic = req.mnemonic }) catch |err| { + logger.err("lnd client initwallet: {!}", .{err}); + return Error.InitLndWallet; + }; + res.deinit(); // unused + + // generate a valid lnd config before unlocking the first time but without auto-unlock. + // the latter works only after first unlock - see below. + // + // important details about a "valid" config is lnd needs correct bitcoind rpc auth, + // which historically has been missing at the initial OS image build. + logger.info("initwallet: generating lnd config file without auto-unlock", .{}); + try self.conf.genLndConfig(.{ .autounlock = false }); + + // 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); + var timer = try types.Timer.start(); + while (timer.read() < 10 * time.ns_per_s) { + const status = client.call(.walletstatus, {}) catch |err| { + logger.info("initwallet: waiting lnd restart: {!}", .{err}); + std.time.sleep(1 * time.ns_per_s); + continue; + }; + defer status.deinit(); + switch (status.value.state) { + .LOCKED => break, + else => |t| { + logger.info("initwallet: waiting lnd restart: {s}", .{@tagName(t)}); + std.time.sleep(1 * time.ns_per_s); + continue; + }, + } + } + + // unlock the wallet for the first time: required after initwallet. + // it generates macaroon files and completes a wallet initialization. + logger.info("initwallet: unlocking new wallet for the first time", .{}); + const res2 = client.call(.unlockwallet, .{ .unlock_password = unlock_pwd }) catch |err| { + logger.err("lnd client unlockwallet: {!}", .{err}); + return Error.UnlockLndWallet; + }; + res2.deinit(); // unused + + // same as above genLndConfig but with auto-unlock enabled. + // no need to restart lnd: it'll pick up the new config on next boot. + logger.info("initwallet: re-generating lnd config with auto-unlock", .{}); + try self.conf.genLndConfig(.{ .autounlock = true }); +} + +/// factory-resets lnd node; wipes out the wallet. +fn resetLndNode(self: *Daemon) !void { + self.mu.lock(); + switch (self.state) { + .stopped, .poweroff, .wallet_reset => { + defer self.mu.unlock(); + switch (self.state) { + .poweroff => return Error.PoweroffActive, + .wallet_reset => return Error.WalletResetActive, + else => return Error.InvalidState, + } + }, + // proceed only when in one of the following states + .running, .standby => {}, + } + const prevstate = self.state; + defer { + self.mu.lock(); + self.state = prevstate; + self.mu.unlock(); + } + self.state = .wallet_reset; + self.mu.unlock(); + + // 1. stop lnd service + try self.services.stopWait(SysService.LND); + + // 2. delete all data directories + try std.fs.cwd().deleteTree(Config.LND_DATA_DIR); + try std.fs.cwd().deleteTree(Config.LND_LOG_DIR); + try std.fs.cwd().deleteFile(Config.LND_WALLETUNLOCK_PATH); + if (std.fs.path.dirname(Config.LND_TLSCERT_PATH)) |dir| { + try std.fs.cwd().deleteTree(dir); + } + // TODO: reset tor hidden service pubkey? + + // 3. generate a new blank config so lnd can start up again and respond + // to status requests. + try self.conf.genLndConfig(.{ .autounlock = false }); + + // 4. start lnd service + try self.services.start(SysService.LND); +} + +/// like resetLndNode but resets only tls certs, nothing else. +/// callers must acquire self.mu. +fn resetLndTlsUnguarded(self: *Daemon) !void { + if (self.lnd_tls_reset_count > 0) { + return error.LndTlsResetCount; + } + switch (self.state) { + .stopped, .poweroff, .wallet_reset => { + defer self.mu.unlock(); + switch (self.state) { + .poweroff => return Error.PoweroffActive, + .wallet_reset => return Error.WalletResetActive, + else => return Error.InvalidState, + } + }, + // proceed only when in one of the following states + .running, .standby => {}, + } + 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); + self.lnd_tls_reset_count += 1; +} + fn switchSysupdates(self: *Daemon, chan: comm.Message.SysupdatesChan) !void { const th = try std.Thread.spawn(.{}, switchSysupdatesThread, .{ self, chan }); th.detach(); @@ -856,8 +1165,8 @@ test "start-stop" { try t.expect(!daemon.wpa_ctrl.attached); try t.expect(daemon.wpa_ctrl.opened); - try t.expect(daemon.services.len > 0); - for (daemon.services) |*sv| { + 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()); } @@ -902,7 +1211,7 @@ test "start-poweroff" { daemon.wait(); try t.expect(daemon.state == .stopped); try t.expect(daemon.poweroff_thread == null); - for (daemon.services) |*sv| { + 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()); diff --git a/src/nd/SysService.zig b/src/nd/SysService.zig index c902556..5ccc153 100644 --- a/src/nd/SysService.zig +++ b/src/nd/SysService.zig @@ -3,6 +3,18 @@ const std = @import("std"); const types = @import("../types.zig"); +// known service names +pub const LND = "lnd"; +pub const BITCOIND = "bitcoind"; + +const Error = error{ + SysServiceStopInProgress, + SysServiceBadStartCode, + SysServiceBadStartTerm, + SysServiceBadStopCode, + SysServiceBadStopTerm, +}; + allocator: std.mem.Allocator, name: []const u8, stop_wait_sec: ?u32 = null, @@ -17,13 +29,15 @@ stop_err: ?anyerror = null, /// the .initial value is a temporary solution until service watcher and start /// are implemnted: at the moment, SysService can only stop services, nothing else. pub const Status = enum(u8) { - initial, // TODO: add .running + initial, // TODO: get rid of "initial" and infer the actual state + started, stopping, stopped, }; const State = union(Status) { initial: void, + started: std.ChildProcess.Term, stopping: void, stopped: std.ChildProcess.Term, }; @@ -64,6 +78,26 @@ pub fn lastStopError(self: *SysService) ?anyerror { return self.stop_err; } +/// launches a service start procedure and returns as soon as the startup script +/// terminates: whether the service actually started successefully is not necessarily +/// indicated by the function return. +pub fn start(self: *SysService) !void { + self.mu.lock(); + defer self.mu.unlock(); + switch (self.stat) { + .stopping => return Error.SysServiceStopInProgress, + .initial, .started, .stopped => {}, // proceed + } + + var proc = types.ChildProcess.init(&.{ "sv", "start", self.name }, self.allocator); + const term = try proc.spawnAndWait(); + self.stat = .{ .started = term }; + switch (term) { + .Exited => |code| if (code != 0) return Error.SysServiceBadStartCode, + else => return Error.SysServiceBadStartTerm, + } +} + /// launches a service stop procedure and returns immediately. /// callers must invoke stopWait to release all resources used by the stop. pub fn stop(self: *SysService) !void { @@ -71,7 +105,7 @@ pub fn stop(self: *SysService) !void { defer self.mu.unlock(); self.stop_err = null; - self.spawnStop() catch |err| { + self.spawnStopUnguarded() catch |err| { self.stop_err = err; return err; }; @@ -84,7 +118,7 @@ pub fn stopWait(self: *SysService) !void { defer self.mu.unlock(); self.stop_err = null; - self.spawnStop() catch |err| { + self.spawnStopUnguarded() catch |err| { self.stop_err = err; return err; }; @@ -96,10 +130,10 @@ pub fn stopWait(self: *SysService) !void { self.stat = .{ .stopped = term }; switch (term) { .Exited => |code| if (code != 0) { - self.stop_err = error.SysServiceBadStopCode; + self.stop_err = Error.SysServiceBadStopCode; }, else => { - self.stop_err = error.SysServiceBadStopTerm; + self.stop_err = Error.SysServiceBadStopTerm; }, } if (self.stop_err) |err| { @@ -109,11 +143,11 @@ pub fn stopWait(self: *SysService) !void { /// actual internal body of SysService.stop: stopWait also uses this. /// callers must hold self.mu. -fn spawnStop(self: *SysService) !void { +fn spawnStopUnguarded(self: *SysService) !void { switch (self.stat) { .stopping => return, // already in progress // intentionally let .stopped state pass through: can't see any downsides. - .initial, .stopped => {}, + .initial, .started, .stopped => {}, } // use arena to simplify stop proc args construction. diff --git a/src/ngui.zig b/src/ngui.zig index d7d7e58..6b15240 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -40,7 +40,7 @@ var last_report: struct { mu: std.Thread.Mutex = .{}, network: ?comm.ParsedMessage = null, // NetworkReport onchain: ?comm.ParsedMessage = null, // OnchainReport - lightning: ?comm.ParsedMessage = null, // LightningReport + lightning: ?comm.ParsedMessage = null, // LightningReport or LightningError fn deinit(self: *@This()) void { self.mu.lock(); @@ -76,7 +76,7 @@ var last_report: struct { } self.onchain = new; }, - .lightning_report => { + .lightning_report, .lightning_error => { if (self.lightning) |old| { old.deinit(); } @@ -233,7 +233,19 @@ fn commThreadLoopCycle() !void { .network_report, .onchain_report, .lightning_report, + .lightning_error, => last_report.replace(msg), + .lightning_genseed_result, + .lightning_ctrlconn, + // TODO: merge standby vs active switch branches + => { + ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + msg.deinit(); + }, + .settings => |sett| { + ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); + msg.deinit(); + }, else => { logger.debug("ignoring {s}: in standby", .{@tagName(msg.value)}); msg.deinit(); @@ -256,10 +268,16 @@ fn commThreadLoopCycle() !void { ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, - .lightning_report => |rep| { - ui.lightning.updateTabPanel(rep) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + .lightning_report, .lightning_error => { + ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); last_report.replace(msg); }, + .lightning_genseed_result, + .lightning_ctrlconn, + => { + ui.lightning.updateTabPanel(msg.value) catch |err| logger.err("lightning.updateTabPanel: {any}", .{err}); + msg.deinit(); + }, .settings => |sett| { ui.settings.update(sett) catch |err| logger.err("settings.update: {any}", .{err}); msg.deinit(); @@ -311,6 +329,11 @@ fn uiThreadLoop() void { logger.err("bitcoin.updateTabPanel: {any}", .{err}); }; } + if (last_report.lightning) |msg| { + ui.lightning.updateTabPanel(msg.value) catch |err| { + logger.err("lightning.updateTabPanel: {any}", .{err}); + }; + } } continue; }, @@ -383,7 +406,7 @@ pub fn main() anyerror!void { comm.initPipe(gpa, .{ .r = std.io.getStdIn(), .w = std.io.getStdOut() }); // initalizes display, input driver and finally creates the user interface. - ui.init() catch |err| { + ui.init(gpa) catch |err| { logger.err("ui.init: {any}", .{err}); return err; }; diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index fd433cd..59b5948 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -118,6 +118,25 @@ fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { } }; comm.write(gpa, w, .{ .poweroff_progress = s3 }) catch |err| logger.err("comm.write: {any}", .{err}); }, + .lightning_genseed => { + time.sleep(2 * time.ns_per_s); + comm.write(gpa, w, .{ .lightning_genseed_result = &.{ + "ability", "dance", "scatter", "raw", "fly", "dentist", "bar", "nominee", + "exhaust", "wine", "snap", "super", "cost", "case", "coconut", "ticket", + "spread", "funny", "grain", "chimney", "aspect", "business", "quiz", "ginger", + } }) catch |err| logger.err("{!}", .{err}); + }, + .lightning_init_wallet => |v| { + logger.info("mnemonic: {s}", .{v.mnemonic}); + time.sleep(3 * time.ns_per_s); + }, + .lightning_get_ctrlconn => { + var conn: comm.Message.LightningCtrlConn = &.{ + .{ .url = "lndconnect://adfkjhadwaepoijsadflkjtrpoijawokjafulkjsadfkjhgjfdskjszd.onion:10009?macaroon=Adasjsadkfljhfjhasdpiuhfiuhawfffoihgpoiadsfjharpoiuhfdsgpoihafdsgpoiheafoiuhasdfhisdufhiuhfewiuhfiuhrfl6prrx", .typ = .lnd_rpc, .perm = .admin }, + .{ .url = "lndconnect://adfkjhadwaepoijsadflkjtrpoijawokjafulkjsadfkjhgjfdskjszd.onion:10010?macaroon=Adasjsadkfljhfjhasdpiuhfiuhawfffoihgpoiadsfjharpoiuhfdsgpoihafdsgpoiheafoiuhasdfhisdufhiuhfewiuhfiuhrfl6prrx", .typ = .lnd_http, .perm = .admin }, + }; + comm.write(gpa, w, .{ .lightning_ctrlconn = conn }) catch |err| logger.err("{!}", .{err}); + }, else => {}, } } @@ -131,6 +150,8 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { var block_count: u32 = 801365; var settings_sent = false; + var lnd_uninited_sent = false; + while (true) { time.sleep(time.ns_per_s); if (sectimer.read() < 3 * time.ns_per_s) { @@ -185,6 +206,11 @@ fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { }; comm.write(gpa, w, .{ .onchain_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + if (!lnd_uninited_sent and block_count % 2 == 0) { + comm.write(gpa, w, .{ .lightning_error = .{ .code = .uninitialized } }) catch |err| logger.err("{any}", .{err}); + lnd_uninited_sent = true; + } + if (block_count % 2 == 0) { const lndrep: comm.Message.LightningReport = .{ .version = "0.16.4-beta commit=v0.16.4-beta", diff --git a/src/test/lndhc.zig b/src/test/lndhc.zig index a9bcfbc..82f5243 100644 --- a/src/test/lndhc.zig +++ b/src/test/lndhc.zig @@ -10,11 +10,22 @@ pub fn main() !void { var client = try lndhttp.Client.init(.{ .allocator = gpa, + .port = 10010, .tlscert_path = "/home/lnd/.lnd/tls.cert", .macaroon_ro_path = "/ssd/lnd/data/chain/bitcoin/mainnet/readonly.macaroon", }); defer client.deinit(); + { + const res = try client.call(.walletstatus, {}); + defer res.deinit(); + std.debug.print("{}\n", .{res.value.state}); + + if (res.value.state == .LOCKED) { + const res2 = try client.call(.unlockwallet, .{ .unlock_password = "45b08eb7bfcf1a7f" }); + defer res2.deinit(); + } + } { const res = try client.call(.getinfo, {}); defer res.deinit(); @@ -31,13 +42,13 @@ pub fn main() !void { // std.debug.print("{any}\n", .{res.value.channels}); //} //{ - // const res = try client.call(.walletstatus, {}); - // defer res.deinit(); - // std.debug.print("{s}\n", .{@tagName(res.value.state)}); - //} - //{ // const res = try client.call(.feereport, {}); // defer res.deinit(); // std.debug.print("{any}\n", .{res.value}); //} + { + const res = try client.call(.genseed, {}); + defer res.deinit(); + std.debug.print("{s}\n", .{res.value.cipher_seed_mnemonic}); + } } diff --git a/src/types.zig b/src/types.zig index ee4f392..b226c69 100644 --- a/src/types.zig +++ b/src/types.zig @@ -48,7 +48,6 @@ pub const IoPipe = struct { } }; -// TODO: turns this into a UniqStringList backed by StringArrayHashMap; also see std.BufSet pub const StringList = struct { l: std.ArrayList([]const u8), allocator: std.mem.Allocator, @@ -62,7 +61,17 @@ pub const StringList = struct { }; } - pub fn deinit(self: *Self) void { + /// duplicates unowned items into the returned list. + pub fn fromUnowned(allocator: std.mem.Allocator, unowned: []const []const u8) !Self { + var list = Self.init(allocator); + errdefer list.deinit(); + for (unowned) |item| { + try list.append(item); + } + return list; + } + + pub fn deinit(self: Self) void { for (self.l.items) |a| { self.allocator.free(a); } diff --git a/src/ui/c/lv_conf.h b/src/ui/c/lv_conf.h index 9d0d342..18b6e77 100644 --- a/src/ui/c/lv_conf.h +++ b/src/ui/c/lv_conf.h @@ -605,7 +605,7 @@ #define LV_USE_GIF 0 /*QR code library*/ -#define LV_USE_QRCODE 0 +#define LV_USE_QRCODE 1 /*FreeType library*/ #define LV_USE_FREETYPE 0 diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index ce4f142..746dbe4 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -92,6 +92,16 @@ extern lv_style_t *nm_style_title() return &style_title; } +/** + * a hack to prevent tabview from switching to the next tab + * on a scroll event, for example coming from a top layer window. + * call this func after deleting all children of an object. + */ +extern void preserve_main_active_tab() +{ + lv_tabview_set_act(tabview, lv_tabview_get_tab_act(tabview), LV_ANIM_OFF); +} + static void textarea_event_cb(lv_event_t *e) { lv_obj_t *textarea = lv_event_get_target(e); diff --git a/src/ui/lightning.zig b/src/ui/lightning.zig index a12abea..d178118 100644 --- a/src/ui/lightning.zig +++ b/src/ui/lightning.zig @@ -5,15 +5,52 @@ const std = @import("std"); const comm = @import("../comm.zig"); const lvgl = @import("lvgl.zig"); +const symbol = @import("symbol.zig"); +const types = @import("../types.zig"); const xfmt = @import("../xfmt.zig"); +const widget = @import("widget.zig"); const logger = std.log.scoped(.ui_lnd); + +const appBitBanana = "BitBanana"; +const appZeus = "Zeus"; +const app_description = std.ComptimeStringMap([:0]const u8, .{ + .{ + appBitBanana, + \\lightning node management for android. + \\website: https://bitbanana.app + \\ + \\follow the website instructions for + \\installing the app. once installed, + \\scan the QR code using the app and + \\complete the setup process. + }, + .{ + appZeus, + \\available for android and iOS. + \\website: https://zeusln.app + \\ + \\follow the website instructions for + \\installing the app. once installed, + \\scan the QR code using the app and + \\complete the setup process. + }, +}); + /// label color mark start to make "label:" part of a "label: value" /// in a different color. const cmark = "#bbbbbb "; +/// a hack to prevent tabview from switching to the next tab +/// on a scroll event when deleting all children of tab.seed_setup.topwin. +/// defined in ui/c/ui.c +extern fn preserve_main_active_tab() void; + var tab: struct { + allocator: std.mem.Allocator, + info: struct { + card: lvgl.Card, // parent alias: lvgl.Label, blockhash: lvgl.Label, currblock: lvgl.Label, @@ -22,6 +59,7 @@ var tab: struct { version: lvgl.Label, }, balance: struct { + card: lvgl.Card, // parent avail: lvgl.Bar, // local vs remote local: lvgl.Label, remote: lvgl.Label, @@ -29,18 +67,119 @@ var tab: struct { pending: lvgl.Label, fees: lvgl.Label, // day, week, month }, - channels_cont: lvgl.FlexLayout, + channels: struct { + card: lvgl.Card, + cont: lvgl.FlexLayout, + }, + pairing: lvgl.Card, + reset: lvgl.Card, + + // elements visibile during lnd startup. + startup: lvgl.FlexLayout, + + // TODO: support wallet manual unlock (LightningError.code.Locked) + + // elements when lnd wallet is uninitialized. + // the actual seed init is in `seed_setup` field. + nowallet: lvgl.FlexLayout, + + seed_setup: ?struct { + topwin: lvgl.Window, + arena: *std.heap.ArenaAllocator, // all non-UI elements are alloc'ed here + mnemonic: ?types.StringList = null, // 24 words genseed result + pairing: ?struct { + // app_description key to connection URL. + // keys are static, values are heap-alloc'ed in `setupPairing`. + urlmap: std.StringArrayHashMap([]const u8), + appsel: lvgl.Dropdown, + appdesc: lvgl.Label, + qr: lvgl.QrCode, // QR-encoded connection URL + qrerr: lvgl.Label, // an error message when QR rendering fails + } = null, + } = null, + + fn initSetup(self: *@This(), topwin: lvgl.Window) !void { + var arena = try self.allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(tab.allocator); + self.seed_setup = .{ .arena = arena, .topwin = topwin }; + } + + fn destroySetup(self: *@This()) void { + if (self.seed_setup == null) { + return; + } + self.seed_setup.?.topwin.destroy(); + self.seed_setup.?.arena.deinit(); + self.allocator.destroy(self.seed_setup.?.arena); + self.seed_setup = null; + preserve_main_active_tab(); + } + + fn setMode(self: *@This(), m: enum { setup, startup, operational }) void { + switch (m) { + .setup => { + self.nowallet.show(); + self.startup.hide(); + self.info.card.hide(); + self.balance.card.hide(); + self.channels.card.hide(); + self.pairing.hide(); + self.reset.hide(); + }, + .startup => { + self.startup.show(); + self.nowallet.hide(); + self.info.card.hide(); + self.balance.card.hide(); + self.channels.card.hide(); + self.pairing.hide(); + self.reset.hide(); + }, + .operational => { + self.info.card.show(); + self.balance.card.show(); + self.channels.card.show(); + self.pairing.show(); + self.reset.show(); + self.nowallet.hide(); + self.startup.hide(); + }, + } + } } = undefined; /// creates the tab content with all elements. /// must be called only once at UI init. -pub fn initTabPanel(cont: lvgl.Container) !void { +pub fn initTabPanel(allocator: std.mem.Allocator, cont: lvgl.Container) !void { + tab.allocator = allocator; const parent = cont.flex(.column, .{}); + const recolor: lvgl.Label.Opt = .{ .recolor = true }; + + // startup + { + tab.startup = try lvgl.FlexLayout.new(parent, .row, .{ .all = .center }); + tab.startup.resizeToMax(); + _ = try lvgl.Spinner.new(tab.startup); + _ = try lvgl.Label.new(tab.startup, "STARTING UP ...", .{}); + } + + // uninitialized wallet state + { + tab.nowallet = try lvgl.FlexLayout.new(parent, .column, .{ .all = .center }); + tab.nowallet.resizeToMax(); + tab.nowallet.setPad(10, .row, .{}); + _ = try lvgl.Label.new(tab.nowallet, "lightning wallet is uninitialized.\ntap the button to start the setup process.", .{}); + const btn = try lvgl.TextButton.new(tab.nowallet, "SETUP NEW WALLET"); + btn.setWidth(lvgl.sizePercent(50)); + _ = btn.on(.click, nm_lnd_setup_click, null); + } + + // regular operational mode // info section { - const card = try lvgl.Card.new(parent, "INFO", .{}); - const row = try lvgl.FlexLayout.new(card, .row, .{}); + tab.info.card = try lvgl.Card.new(parent, "INFO", .{}); + const row = try lvgl.FlexLayout.new(tab.info.card, .row, .{}); row.setHeightToContent(); row.setWidth(lvgl.sizePercent(100)); row.clearFlag(.scrollable); @@ -49,22 +188,22 @@ pub fn initTabPanel(cont: lvgl.Container) !void { left.setHeightToContent(); left.setWidth(lvgl.sizePercent(50)); left.setPad(10, .row, .{}); - tab.info.alias = try lvgl.Label.new(left, "ALIAS\n", .{ .recolor = true }); - tab.info.pubkey = try lvgl.Label.new(left, "PUBKEY\n", .{ .recolor = true }); - tab.info.version = try lvgl.Label.new(left, "VERSION\n", .{ .recolor = true }); + tab.info.alias = try lvgl.Label.new(left, "ALIAS\n", recolor); + tab.info.pubkey = try lvgl.Label.new(left, "PUBKEY\n", recolor); + tab.info.version = try lvgl.Label.new(left, "VERSION\n", recolor); // right column const right = try lvgl.FlexLayout.new(row, .column, .{}); right.setHeightToContent(); right.setWidth(lvgl.sizePercent(50)); right.setPad(10, .row, .{}); - tab.info.currblock = try lvgl.Label.new(right, "HEIGHT\n", .{ .recolor = true }); - tab.info.blockhash = try lvgl.Label.new(right, "BLOCK HASH\n", .{ .recolor = true }); - tab.info.npeers = try lvgl.Label.new(right, "CONNECTED PEERS\n", .{ .recolor = true }); + tab.info.currblock = try lvgl.Label.new(right, "HEIGHT\n", recolor); + tab.info.blockhash = try lvgl.Label.new(right, "BLOCK HASH\n", recolor); + tab.info.npeers = try lvgl.Label.new(right, "CONNECTED PEERS\n", recolor); } // balance section { - const card = try lvgl.Card.new(parent, "BALANCE", .{}); - const row = try lvgl.FlexLayout.new(card, .row, .{}); + tab.balance.card = try lvgl.Card.new(parent, "BALANCE", .{}); + const row = try lvgl.FlexLayout.new(tab.balance.card, .row, .{}); row.setWidth(lvgl.sizePercent(100)); row.clearFlag(.scrollable); // left column @@ -76,31 +215,298 @@ pub fn initTabPanel(cont: lvgl.Container) !void { const subrow = try lvgl.FlexLayout.new(left, .row, .{ .main = .space_between }); subrow.setWidth(lvgl.sizePercent(90)); subrow.setHeightToContent(); - tab.balance.local = try lvgl.Label.new(subrow, "LOCAL\n", .{ .recolor = true }); - tab.balance.remote = try lvgl.Label.new(subrow, "REMOTE\n", .{ .recolor = true }); + tab.balance.local = try lvgl.Label.new(subrow, "LOCAL\n", recolor); + tab.balance.remote = try lvgl.Label.new(subrow, "REMOTE\n", recolor); // right column const right = try lvgl.FlexLayout.new(row, .column, .{}); right.setWidth(lvgl.sizePercent(50)); right.setPad(10, .row, .{}); - tab.balance.pending = try lvgl.Label.new(right, "PENDING\n", .{ .recolor = true }); - tab.balance.unsettled = try lvgl.Label.new(right, "UNSETTLED\n", .{ .recolor = true }); + tab.balance.pending = try lvgl.Label.new(right, "PENDING\n", recolor); + tab.balance.unsettled = try lvgl.Label.new(right, "UNSETTLED\n", recolor); // bottom - tab.balance.fees = try lvgl.Label.new(card, "ACCUMULATED FORWARDING FEES\n", .{ .recolor = true }); + tab.balance.fees = try lvgl.Label.new(tab.balance.card, "ACCUMULATED FORWARDING FEES\n", recolor); } // channels section { - const card = try lvgl.Card.new(parent, "CHANNELS", .{}); - tab.channels_cont = try lvgl.FlexLayout.new(card, .column, .{}); - tab.channels_cont.setHeightToContent(); - tab.channels_cont.setWidth(lvgl.sizePercent(100)); - tab.channels_cont.clearFlag(.scrollable); - tab.channels_cont.setPad(10, .row, .{}); + tab.channels.card = try lvgl.Card.new(parent, "CHANNELS", .{}); + tab.channels.cont = try lvgl.FlexLayout.new(tab.channels.card, .column, .{}); + tab.channels.cont.setHeightToContent(); + tab.channels.cont.setWidth(lvgl.sizePercent(100)); + tab.channels.cont.clearFlag(.scrollable); + tab.channels.cont.setPad(10, .row, .{}); + } + // pairing section + { + tab.pairing = try lvgl.Card.new(parent, "PAIRING", .{}); + const row = try lvgl.FlexLayout.new(tab.pairing, .row, .{ .width = lvgl.sizePercent(100), .height = .content }); + const lb = try lvgl.Label.new(row, "tap the button on the right to start pairing with a phone.", .{}); + lb.flexGrow(1); + const btn = try lvgl.TextButton.new(row, "PAIR"); + btn.flexGrow(1); + _ = btn.on(.click, nm_lnd_pair_click, null); + } + // node reset section + { + tab.reset = try lvgl.Card.new(parent, symbol.Warning ++ " FACTORY RESET", .{}); + const row = try lvgl.FlexLayout.new(tab.reset, .row, .{ .width = lvgl.sizePercent(100), .height = .content }); + const lb = try lvgl.Label.new(row, "resetting the node restores its state to a factory setup.", .{}); + lb.flexGrow(1); + const btn = try lvgl.TextButton.new(row, "RESET"); + btn.flexGrow(1); + btn.addStyle(lvgl.nm_style_btn_red(), .{}); + _ = btn.on(.click, nm_lnd_reset_click, null); } + + tab.setMode(.startup); } -/// updates the tab with new data from the report. +/// updates the tab with new data from a `comm.Message` tagged with .lightning_xxx, /// the tab must be inited first with initTabPanel. -pub fn updateTabPanel(rep: comm.Message.LightningReport) !void { +pub fn updateTabPanel(msg: comm.Message) !void { + return switch (msg) { + .lightning_error => |lnerr| switch (lnerr.code) { + .uninitialized => tab.setMode(.setup), + // TODO: handle "wallet locked" and other errors + else => tab.setMode(.startup), + }, + .lightning_report => |rep| blk: { + tab.setMode(.operational); + break :blk updateReport(rep); + }, + .lightning_genseed_result => |mnemonic| confirmSetupSeed(mnemonic), + .lightning_ctrlconn => |conn| setupPairing(conn), + else => error.UnsupportedMessage, + }; +} + +export fn nm_lnd_setup_click(_: *lvgl.LvEvent) void { + startSeedSetup() catch |err| logger.err("startSeedSetup: {any}", .{err}); +} + +export fn nm_lnd_pair_click(_: *lvgl.LvEvent) void { + startPairing() catch |err| logger.err("startPairing: {any}", .{err}); +} + +export fn nm_lnd_reset_click(_: *lvgl.LvEvent) void { + promptNodeReset() catch |err| logger.err("resetNode: {any}", .{err}); +} + +export fn nm_lnd_setup_finish(_: *lvgl.LvEvent) void { + tab.destroySetup(); +} + +fn startSeedSetup() !void { + const win = try lvgl.Window.newTop(60, " " ++ symbol.LightningBolt ++ " LIGHTNING SETUP"); + try tab.initSetup(win); + errdefer tab.destroySetup(); // TODO: display an error instead + const wincont = win.content().flex(.row, .{ .all = .center }); + _ = try lvgl.Spinner.new(wincont); + _ = try lvgl.Label.new(wincont, "GENERATING SEED ...", .{}); + try comm.pipeWrite(.lightning_genseed); +} + +/// similar to `startSeedSetup` but used when seed is already setup, +/// at any time later. +/// reuses tab.seed_setup elements for the same purpose. +fn startPairing() !void { + const win = try lvgl.Window.newTop(60, " " ++ symbol.LightningBolt ++ " PAIRING SETUP"); + try tab.initSetup(win); + errdefer tab.destroySetup(); // TODO: display an error instead + const wincont = win.content().flex(.row, .{ .all = .center }); + _ = try lvgl.Spinner.new(wincont); + _ = try lvgl.Label.new(wincont, "GATHERING CONNECTION DATA ...", .{}); + try comm.pipeWrite(.lightning_get_ctrlconn); +} + +fn confirmSetupSeed(mnemonic: []const []const u8) !void { + errdefer tab.destroySetup(); // TODO: display an error instead + if (tab.seed_setup == null) { + return error.LightningSetupInactive; + } + if (mnemonic.len != 24) { + return error.InvalidMnemonicLen; + } + tab.seed_setup.?.mnemonic = try types.StringList.fromUnowned(tab.seed_setup.?.arena.allocator(), mnemonic); + + const wincont = tab.seed_setup.?.topwin.content().flex(.column, .{}); + wincont.deleteChildren(); + preserve_main_active_tab(); + + _ = try lvgl.Label.new(wincont, + \\the seed below is the master key of this lightning node, allowing + \\FULL CONTROL as well as recovery of funds in case of a device fatal failure. + , .{}); + + const seedcard = try lvgl.Card.new(wincont, "SEED", .{}); + const seedcols = try lvgl.FlexLayout.new(seedcard, .row, .{ .width = lvgl.sizePercent(100), .height = .content }); + const cols: [3]lvgl.FlexLayout = .{ + try lvgl.FlexLayout.new(seedcols, .column, .{ .width = lvgl.sizePercent(30), .height = .content }), + try lvgl.FlexLayout.new(seedcols, .column, .{ .width = lvgl.sizePercent(30), .height = .content }), + try lvgl.FlexLayout.new(seedcols, .column, .{ .width = lvgl.sizePercent(30), .height = .content }), + }; + var buf: [32]u8 = undefined; + for (tab.seed_setup.?.mnemonic.?.items(), 0..) |word, i| { + _ = try lvgl.Label.newFmt(cols[i / 8], &buf, "{d: >2}. {s}", .{ i + 1, word }, .{}); + } + + _ = try lvgl.Label.new(wincont, + \\it is DISPLAYED ONCE, this time only. the recommendation is to copy it + \\over to a non-digital medium, away from easily accessible places. + \\failure to secure the seed leads to UNRECOVERABLE LOSS of all funds. + , .{}); + + const btnrow = try lvgl.FlexLayout.new(wincont, .row, .{ + .width = lvgl.sizePercent(100), + .height = .content, + .main = .space_between, + }); + const cancel_btn = try lvgl.TextButton.new(btnrow, "CANCEL"); + cancel_btn.setWidth(lvgl.sizePercent(30)); + cancel_btn.addStyle(lvgl.nm_style_btn_red(), .{}); + _ = cancel_btn.on(.click, nm_lnd_setup_finish, null); + + const proceed_btn = try lvgl.TextButton.new(btnrow, "PROCEED " ++ symbol.Right); + proceed_btn.setWidth(lvgl.sizePercent(30)); + _ = proceed_btn.on(.click, nm_lnd_setup_commit_seed, null); +} + +export fn nm_lnd_setup_commit_seed(_: *lvgl.LvEvent) void { + setupCommitSeed() catch |err| logger.err("setupCommitSeed: {any}", .{err}); +} + +fn setupCommitSeed() !void { + errdefer tab.destroySetup(); // TODO: display an error instead + if (tab.seed_setup == null) { + return error.LightningSetupInactive; + } + if (tab.seed_setup.?.mnemonic == null) { + return error.LightningSetupNullMnemonic; + } + const wincont = tab.seed_setup.?.topwin.content().flex(.row, .{ .all = .center }); + wincont.deleteChildren(); + preserve_main_active_tab(); + _ = try lvgl.Spinner.new(wincont); + _ = try lvgl.Label.new(wincont, "INITIALIZING WALLET ...", .{}); + try comm.pipeWrite(.{ .lightning_init_wallet = .{ .mnemonic = tab.seed_setup.?.mnemonic.?.items() } }); + try comm.pipeWrite(.lightning_get_ctrlconn); +} + +fn setupPairing(conn: comm.Message.LightningCtrlConn) !void { + errdefer tab.destroySetup(); // TODO: display an error instead + if (tab.seed_setup == null) { + return error.LightningSetupInactive; + } + + const alloc = tab.seed_setup.?.arena.allocator(); + var urlmap = std.StringArrayHashMap([]const u8).init(alloc); + for (conn) |ctrl| { + if (ctrl.perm != .admin) { + continue; + } + // TODO: tor vs i2p vs clearnet vs nebula + switch (ctrl.typ) { + .lnd_rpc => try urlmap.put(appBitBanana, try alloc.dupe(u8, ctrl.url)), + .lnd_http => try urlmap.put(appZeus, try alloc.dupe(u8, ctrl.url)), + } + } + const appsel_options = try std.mem.joinZ(alloc, "\n", urlmap.keys()); + + const wincont = tab.seed_setup.?.topwin.content().flex(.row, .{ .width = lvgl.sizePercent(100), .height = .content }); + wincont.deleteChildren(); + preserve_main_active_tab(); + + const colopt = lvgl.FlexLayout.AlignOpt{ + .width = lvgl.sizePercent(50), + .height = .{ .fixed = lvgl.sizePercent(95) }, + }; + const leftcol = try lvgl.FlexLayout.new(wincont, .column, colopt); + leftcol.setPad(10, .row, .{}); + const appsel = try lvgl.Dropdown.new(leftcol, appsel_options); + appsel.setWidth(lvgl.sizePercent(100)); + _ = appsel.on(.value_changed, nm_lnd_setup_appsel_changed, null); + const appdesc = try lvgl.Label.new(leftcol, "", .{}); + appdesc.flexGrow(1); + const donebtn = try lvgl.TextButton.new(leftcol, "DONE"); + donebtn.setWidth(lvgl.sizePercent(100)); + _ = donebtn.on(.click, nm_lnd_setup_finish, null); + + const rightcol = try lvgl.FlexLayout.new(wincont, .column, colopt); + // QR code widget requires fixed size value. assuming two flex columns split the screen + // at 50%, the appsel dropdown's content on the left should be the same as the desired + // QR code size on the right. + wincont.recalculateLayout(); // ensure contentWidth returns correct value + const qr = try lvgl.QrCode.new(rightcol, appsel.contentWidth(), null); + const qrerr = try lvgl.Label.new(rightcol, "QR data too large to display", .{}); + qrerr.hide(); + + // pairing struct must be set last, past all try/catch err. + // otherwise, errdefer will double free urlmap: once in here, second in tab.destroySetup. + tab.seed_setup.?.pairing = .{ + .urlmap = urlmap, + .appsel = appsel, + .appdesc = appdesc, + .qr = qr, + .qrerr = qrerr, + }; + updatePairingApp(); +} + +export fn nm_lnd_setup_appsel_changed(_: *lvgl.LvEvent) void { + updatePairingApp(); +} + +fn updatePairingApp() void { + const pairing = tab.seed_setup.?.pairing.?; + var buf: [128]u8 = undefined; + const appname = pairing.appsel.getSelectedStr(&buf); + if (app_description.get(appname)) |appdesc| { + pairing.appdesc.setTextStatic(appdesc); + pairing.qr.show(); + pairing.qrerr.hide(); + pairing.qr.setQrData(pairing.urlmap.get(appname).?) catch |err| { + logger.err("updatePairingApp: setQrData: {!}", .{err}); + pairing.qr.hide(); + pairing.qrerr.show(); + }; + } else { + logger.err("updatePairingApp: unknown app name [{s}]", .{appname}); + } +} + +fn promptNodeReset() !void { + const proceed: [*:0]const u8 = "PROCEED"; // btn idx 0 + const abort: [*:0]const u8 = "CANCEL"; // btn idx 1 + const title = " " ++ symbol.Warning ++ " LIGHTNING NODE RESET"; + const text = + \\ARE YOU SURE? + \\ + \\once reset, all funds managed by this node become + \\permanently inaccessible unless a copy of the seed + \\is available. + \\ + \\the mnemonic seed allows restoring access to the + \\on-chain portion of the funds. + ; + widget.modal(title, text, &.{ proceed, abort }, nodeResetModalCallback) catch |err| { + logger.err("promptNodeReset: modal: {any}", .{err}); + }; +} + +fn nodeResetModalCallback(btn_idx: usize) align(@alignOf(widget.ModalButtonCallbackFn)) void { + defer preserve_main_active_tab(); + // proceed = 0, cancel = 1 + if (btn_idx != 0) { + return; + } + comm.pipeWrite(.lightning_reset) catch |err| { + logger.err("nodeResetModalCallback: failed to request node reset: {!}", .{err}); + return; + }; + tab.setMode(.startup); +} + +/// updates the tab when in regular operational mode. +fn updateReport(rep: comm.Message.LightningReport) !void { var buf: [512]u8 = undefined; // info section @@ -132,9 +538,9 @@ pub fn updateTabPanel(rep: comm.Message.LightningReport) !void { }); // channels section - tab.channels_cont.deleteChildren(); + tab.channels.cont.deleteChildren(); for (rep.channels) |ch| { - const chbox = (try lvgl.Container.new(tab.channels_cont)).flex(.column, .{}); + const chbox = (try lvgl.Container.new(tab.channels.cont)).flex(.column, .{}); chbox.setWidth(lvgl.sizePercent(100)); chbox.setHeightToContent(); _ = try switch (ch.state) { diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index 195eeb3..977fb29 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -162,6 +162,10 @@ pub const LvEvent = opaque { pub fn userdata(self: *LvEvent) ?*anyopaque { return lv_event_get_user_data(self); } + + pub fn stopBubbling(self: *LvEvent) void { + lv_event_stop_bubbling(self); + } }; /// represents lv_disp_t in C. @@ -350,6 +354,12 @@ pub const BaseObjMethods = struct { nm_obj_set_userdata(self.lvobj, data); } + /// updates layout of all children so that functions like `WidgetMethods.contentWidth` + /// return correct results, when done in a single LVGL loop iteration. + pub fn recalculateLayout(self: anytype) void { + lv_obj_update_layout(self.lvobj); + } + /// creates a new event handler where cb is called upon event with the filter code. /// to make cb called on any event, use EventCode.all filter. /// multiple event handlers are called in the same order as they were added. @@ -361,6 +371,10 @@ pub const BaseObjMethods = struct { /// methods applicable to visible objects like labels, buttons and containers. pub const WidgetMethods = struct { + pub fn contentWidth(self: anytype) Coord { + return lv_obj_get_content_width(self.lvobj); + } + /// sets object horizontal length. pub fn setWidth(self: anytype, val: Coord) void { lv_obj_set_width(self.lvobj, val); @@ -553,6 +567,8 @@ pub const FlexLayout = struct { cross: AlignCross = .start, track: Align = .start, all: ?Align = null, // overrides all 3 above + width: ?Coord = null, + height: ?union(enum) { fixed: Coord, content } = null, }; /// creates a new object with flex layout and some default padding. @@ -563,6 +579,15 @@ pub const FlexLayout = struct { lv_obj_remove_style(obj, null, bgsel.value()); const flex = adopt(obj, flow, opt); flex.padColumnDefault(); + if (opt.width) |w| { + flex.setWidth(w); + } + if (opt.height) |h| { + switch (h) { + .content => flex.setHeightToContent(), + .fixed => |v| flex.setHeight(v), + } + } return flex; } @@ -849,7 +874,7 @@ pub const Dropdown = struct { } /// returns selected option as a slice of the buf. - /// LVGL's lv_dropdown drawing supports up to 128 chars. + /// LVGL's lv_dropdown supports up to 128 chars. pub fn getSelectedStr(self: Dropdown, buf: []u8) [:0]const u8 { const buflen: u32 = @min(buf.len, std.math.maxInt(u32)); lv_dropdown_get_selected_str(self.lvobj, buf.ptr, buflen); @@ -860,6 +885,34 @@ pub const Dropdown = struct { } }; +pub const QrCode = struct { + lvobj: *LvObj, + + pub usingnamespace BaseObjMethods; + pub usingnamespace WidgetMethods; + + pub fn new(parent: anytype, size: Coord, data: ?[]const u8) !QrCode { + const o = lv_qrcode_create(parent.lvobj, size, Black, White) orelse return error.OutOfMemory; + const q = QrCode{ .lvobj = o }; + errdefer q.destroy(); + if (data) |d| { + try q.setQrData(d); + } + return q; + } + + pub fn setQrData(self: QrCode, data: []const u8) !void { + if (data.len > std.math.maxInt(u32)) { + return error.QrCodeDataTooLarge; + } + const len: u32 = @truncate(data.len); + const res = lv_qrcode_update(self.lvobj, data.ptr, len); + if (res != c.LV_RES_OK) { + return error.QrCodeSetData; + } + } +}; + /// represents lv_obj_t type in C. pub const LvObj = opaque { /// feature-flags controlling object's behavior. @@ -1027,6 +1080,7 @@ extern fn lv_event_get_code(e: *LvEvent) LvEvent.Code; extern fn lv_event_get_current_target(e: *LvEvent) *LvObj; extern fn lv_event_get_target(e: *LvEvent) *LvObj; extern fn lv_event_get_user_data(e: *LvEvent) ?*anyopaque; +extern fn lv_event_stop_bubbling(e: *LvEvent) void; extern fn lv_obj_add_event_cb(obj: *LvObj, cb: LvEvent.Callback, filter: LvEvent.Code, userdata: ?*anyopaque) *LvEvent.Descriptor; // display and screen functions ---------------------------------------------- @@ -1085,6 +1139,8 @@ extern fn lv_obj_create(parent: ?*LvObj) ?*LvObj; extern fn lv_obj_del(obj: *LvObj) void; /// deletes children of the obj. extern fn lv_obj_clean(obj: *LvObj) void; +/// recalculates an object layout based on all its children. +pub extern fn lv_obj_update_layout(obj: *const LvObj) void; extern fn lv_obj_add_state(obj: *LvObj, c.lv_state_t) void; extern fn lv_obj_clear_state(obj: *LvObj, c.lv_state_t) void; @@ -1096,6 +1152,7 @@ extern fn lv_obj_align(obj: *LvObj, a: c.lv_align_t, x: c.lv_coord_t, y: c.lv_co extern fn lv_obj_set_height(obj: *LvObj, h: c.lv_coord_t) void; extern fn lv_obj_set_width(obj: *LvObj, w: c.lv_coord_t) void; extern fn lv_obj_set_size(obj: *LvObj, w: c.lv_coord_t, h: c.lv_coord_t) void; +extern fn lv_obj_get_content_width(obj: *const LvObj) c.lv_coord_t; extern fn lv_obj_set_flex_flow(obj: *LvObj, flow: c.lv_flex_flow_t) void; extern fn lv_obj_set_flex_grow(obj: *LvObj, val: u8) void; @@ -1134,3 +1191,6 @@ extern fn lv_bar_set_range(bar: *LvObj, min: i32, max: i32) void; extern fn lv_win_create(parent: *LvObj, header_height: c.lv_coord_t) ?*LvObj; extern fn lv_win_add_title(win: *LvObj, title: [*:0]const u8) ?*LvObj; extern fn lv_win_get_content(win: *LvObj) *LvObj; + +extern fn lv_qrcode_create(parent: *LvObj, size: c.lv_coord_t, dark: Color, light: Color) ?*LvObj; +extern fn lv_qrcode_update(qrcode: *LvObj, data: *const anyopaque, data_len: u32) c.lv_res_t; diff --git a/src/ui/symbol.zig b/src/ui/symbol.zig index 9ee63b0..8cbe1fc 100644 --- a/src/ui/symbol.zig +++ b/src/ui/symbol.zig @@ -1,5 +1,7 @@ ///! see lv_symbols_def.h +pub const LightningBolt = &[_]u8{ 0xef, 0x83, 0xa7 }; pub const Loop = &[_]u8{ 0xef, 0x81, 0xb9 }; pub const Ok = &[_]u8{ 0xef, 0x80, 0x8c }; pub const Power = &[_]u8{ 0xef, 0x80, 0x91 }; +pub const Right = &[_]u8{ 0xef, 0x81, 0x94 }; pub const Warning = &[_]u8{ 0xef, 0x81, 0xb1 }; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index e892f46..06e71cd 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -14,9 +14,16 @@ pub const settings = @import("settings.zig"); const logger = std.log.scoped(.ui); +// defined in src/ui/c/ui.c +// calls back into nm_create_xxx_panel functions defined here during init. extern "c" fn nm_ui_init(disp: *lvgl.LvDisp) c_int; -pub fn init() !void { +// global allocator set on init. +// must be set before a call to nm_ui_init. +var allocator: std.mem.Allocator = undefined; + +pub fn init(gpa: std.mem.Allocator) !void { + allocator = gpa; lvgl.init(); const disp = try drv.initDisplay(); drv.initInput() catch |err| { @@ -47,7 +54,7 @@ export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { } export fn nm_create_lightning_panel(parent: *lvgl.LvObj) c_int { - lightning.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + lightning.initTabPanel(allocator, lvgl.Container{ .lvobj = parent }) catch |err| { logger.err("createLightningPanel: {any}", .{err}); return -1; }; -- 2.46.2