nd,ngui: add lightning setup process, phone pairing and reset #31

Manually merged
x1ddos merged 1 commits from lndsetup into master 1 year ago

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

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

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

@ -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});
}
}
// 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.err("sendLightningReport: {any}", .{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());

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

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

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

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

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

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

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

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

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

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

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