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