nd: add lnd lightning report sent to UI every min

similarly to 2642a554, this adds an lnd HTTP client able to make some
queries like getinfo. the daemon then uses the client to compose a
lightning status report and sends it over to ngui through comms,
periodically.

there's also a client playground built on demand with
"zig build lndhc".
pull/26/head
alex 1 year ago
parent 328df67c5d
commit 52a8c1fb1a
Signed by: x1ddos
GPG Key ID: FDEFB4A63CBD8460

@ -156,6 +156,21 @@ pub fn build(b: *std.Build) void {
btcrpc_build_step.dependOn(&b.addInstallArtifact(btcrpc, .{}).step);
}
// lnd HTTP API client playground
{
const lndhc = b.addExecutable(.{
.name = "lndhc",
.root_source_file = .{ .path = "src/test/lndhc.zig" },
.target = target,
.optimize = optimize,
});
lndhc.strip = strip;
lndhc.addModule("lndhttp", b.createModule(.{ .source_file = .{ .path = "src/lndhttp.zig" } }));
const lndhc_build_step = b.step("lndhc", "lnd HTTP API client playground");
lndhc_build_step.dependOn(&b.addInstallArtifact(lndhc, .{}).step);
}
// default build step
const build_all_step = b.step("all", "build nd and ngui (default step)");
build_all_step.dependOn(ngui_build_step);

@ -28,6 +28,7 @@ pub const Message = union(MessageTag) {
get_network_report: GetNetworkReport,
poweroff_progress: PoweroffProgress,
bitcoind_report: BitcoindReport,
lightning_report: LightningReport,
pub const WifiConnect = struct {
ssid: []const u8,
@ -81,6 +82,38 @@ pub const Message = union(MessageTag) {
fullrbf: bool,
},
};
pub const LightningReport = struct {
version: []const u8,
pubkey: []const u8,
alias: []const u8,
npeers: u32,
height: u32,
hash: []const u8,
sync: struct { chain: bool, graph: bool },
uris: []const []const u8,
/// only lightning channels balance is reported here
totalbalance: struct { local: i64, remote: i64, unsettled: i64, pending: i64 },
totalfees: struct { day: u64, week: u64, month: u64 }, // sats
channels: []const struct {
id: ?[]const u8 = null, // null for pending_xxx state
state: enum { active, inactive, pending_open, pending_close },
private: bool,
point: []const u8, // funding txid:index
closetxid: ?[]const u8 = null, // non-null for pending_close
peer_pubkey: []const u8,
peer_alias: []const u8,
capacity: i64,
balance: struct { local: i64, remote: i64, unsettled: i64, limbo: i64 },
totalsats: struct { sent: i64, received: i64 },
fees: struct {
base: i64, // msat
ppm: i64, // per milli-satoshis, in millionths of satoshi
// TODO: remote base and ppm from getchaninfo
// https://docs.lightning.engineering/lightning-network-tools/lnd/channel-fees
},
},
};
};
/// it is important to preserve ordinal values for future compatiblity,
@ -100,7 +133,9 @@ pub const MessageTag = enum(u16) {
poweroff_progress = 0x09,
// nd -> ngui: bitcoin core daemon status report
bitcoind_report = 0x0a,
// next: 0x0b
// nd -> ngui: lnd status and stats report
lightning_report = 0x0b,
// next: 0x0c
};
/// the return value type from `read` fn.
@ -173,6 +208,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void {
.get_network_report => try json.stringify(msg.get_network_report, .{}, data.writer()),
.poweroff_progress => try json.stringify(msg.poweroff_progress, .{}, data.writer()),
.bitcoind_report => try json.stringify(msg.bitcoind_report, .{}, data.writer()),
.lightning_report => try json.stringify(msg.lightning_report, .{}, data.writer()),
}
if (data.items.len > std.math.maxInt(u64)) {
return Error.CommWriteTooLarge;

@ -0,0 +1,327 @@
//! lnd lightning HTTP client and utility functions.
const std = @import("std");
const types = @import("types.zig");
pub const Client = struct {
allocator: std.mem.Allocator,
hostname: []const u8 = "localhost",
port: u16 = 10010,
apibase: []const u8, // https://localhost:10010
macaroon: struct {
readonly: []const u8,
admin: ?[]const u8,
},
httpClient: std.http.Client,
pub const ApiMethod = enum {
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",
.getinfo => "v1/getinfo",
.getnetworkinfo => "v1/graph/info",
.listchannels => "v1/channels",
.pendingchannels => "v1/channels/pending",
.walletbalance => "v1/balance/blockchain",
.walletstatus => "v1/state",
};
}
};
pub fn MethodArgs(comptime m: ApiMethod) type {
return switch (m) {
.listchannels => struct {
status: ?enum { active, inactive } = null,
advert: ?enum { public, private } = null,
peer: ?[]const u8 = null, // hex pubkey; filter out non-matching peers
peer_alias_lookup: bool, // performance penalty if set to true
},
else => void,
};
}
pub fn ResultValue(comptime m: ApiMethod) type {
return switch (m) {
.feereport => FeeReport,
.getinfo => LndInfo,
.getnetworkinfo => NetworkInfo,
.listchannels => ChannelsList,
.pendingchannels => PendingList,
.walletbalance => WalletBalance,
.walletstatus => WalletStatus,
};
}
pub const InitOpt = struct {
allocator: std.mem.Allocator,
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_admin_path: ?[]const u8 = null, // required only for requests mutating lnd state
};
/// opt slices are dup'ed and need not be kept alive.
/// 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);
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,
.macaroon = .{ .readonly = mac_ro, .admin = mac_admin },
.httpClient = std.http.Client{
.allocator = opt.allocator,
.ca_bundle = ca,
.next_https_rescan_certs = false, // use only the provided CA bundle above
},
};
}
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);
}
}
pub fn Result(comptime m: ApiMethod) type {
return if (@TypeOf(ResultValue(m)) == void) void else types.Deinitable(ResultValue(m));
}
pub fn call(self: *Client, comptime apimethod: ApiMethod, args: MethodArgs(apimethod)) !Result(apimethod) {
const formatted = try self.formatreq(apimethod, args);
defer formatted.deinit();
const reqinfo = formatted.value;
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();
try req.start();
if (reqinfo.payload) |p| {
try req.writer().writeAll(p);
try req.finish();
}
try req.wait();
if (req.response.status.class() != .success) {
return error.LndHttpBadStatusCode;
}
if (@TypeOf(Result(apimethod)) == void) {
return; // void response; need no json parsing
}
const body = try req.reader().readAllAlloc(self.allocator, 1 << 20); // 1Mb should be enough for all response types
defer self.allocator.free(body);
var res = try Result(apimethod).init(self.allocator);
errdefer res.deinit();
res.value = try std.json.parseFromSliceLeaky(ResultValue(apimethod), res.arena.allocator(), body, .{
.ignore_unknown_fields = true,
.allocate = .alloc_always,
});
return res;
}
const HttpReqInfo = struct {
httpmethod: std.http.Method,
url: std.Uri,
headers: std.http.Headers,
payload: ?[]const u8,
};
fn formatreq(self: Client, comptime apimethod: ApiMethod, args: MethodArgs(apimethod)) !types.Deinitable(HttpReqInfo) {
const authHeaderName = "grpc-metadata-macaroon";
var reqinfo = try types.Deinitable(HttpReqInfo).init(self.allocator);
errdefer reqinfo.deinit();
const arena = reqinfo.arena.allocator();
reqinfo.value = switch (apimethod) {
.feereport, .getinfo, .getnetworkinfo, .pendingchannels, .walletbalance, .walletstatus => |m| .{
.httpmethod = .GET,
.url = try std.Uri.parse(try std.fmt.allocPrint(arena, "{s}/{s}", .{ self.apibase, m.apipath() })),
.headers = blk: {
var h = std.http.Headers{ .allocator = arena };
try h.append(authHeaderName, self.macaroon.readonly);
break :blk h;
},
.payload = null,
},
.listchannels => .{
.httpmethod = .GET,
.url = blk: {
var buf = std.ArrayList(u8).init(arena);
const w = buf.writer();
try std.fmt.format(w, "{s}/v1/channels?peer_alias_lookup={}", .{ self.apibase, args.peer_alias_lookup });
if (args.status) |v| switch (v) {
.active => try w.writeAll("&active_only=true"),
.inactive => try w.writeAll("&inactive_only=true"),
};
if (args.advert) |v| switch (v) {
.public => try w.writeAll("&public_only=true"),
.private => try w.writeAll("&private_only=true"),
};
if (args.peer) |v| {
// TODO: sanitize; Uri.writeEscapedQuery(w, q);
try std.fmt.format(w, "&peer={s}", .{v});
}
break :blk try std.Uri.parse(buf.items);
},
.headers = blk: {
var h = std.http.Headers{ .allocator = arena };
try h.append(authHeaderName, self.macaroon.readonly);
break :blk h;
},
.payload = null,
},
};
return reqinfo;
}
/// 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 });
defer file.close();
const cont = try file.readToEndAlloc(gpa, 1024);
defer gpa.free(cont);
return std.fmt.allocPrint(gpa, "{}", .{std.fmt.fmtSliceHexLower(cont)});
}
};
/// general info and stats around the host lnd.
pub const LndInfo = struct {
version: []const u8,
identity_pubkey: []const u8,
alias: []const u8,
color: []const u8,
num_pending_channels: u32,
num_active_channels: u32,
num_inactive_channels: u32,
num_peers: u32,
block_height: u32,
block_hash: []const u8,
synced_to_chain: bool,
synced_to_graph: bool,
chains: []const struct {
chain: []const u8,
network: []const u8,
},
uris: []const []const u8,
// best_header_timestamp and features?
};
pub const NetworkInfo = struct {
graph_diameter: u32,
avg_out_degree: f32,
max_out_degree: u32,
num_nodes: u32,
num_channels: u32,
total_network_capacity: i64,
avg_channel_size: f64,
min_channel_size: i64,
max_channel_size: i64,
median_channel_size_sat: i64,
num_zombie_chans: u64,
};
pub const FeeReport = struct {
day_fee_sum: u64,
week_fee_sum: u64,
month_fee_sum: u64,
channel_fees: []struct {
chan_id: []const u8,
channel_point: []const u8,
base_fee_msat: i64,
fee_per_mil: i64, // per milli-satoshis, in millionths of satoshi
fee_rate: f64, // fee_per_mil/10^6, in milli-satoshis
},
};
pub const ChannelsList = struct {
channels: []struct {
chan_id: []const u8, // [0..3]: height, [3..6]: index within block, [6..8]: chan out idx
remote_pubkey: []const u8,
channel_point: []const u8, // txid:index of the funding tx
capacity: i64,
local_balance: i64,
remote_balance: i64,
unsettled_balance: i64,
total_satoshis_sent: i64,
total_satoshis_received: i64,
active: bool,
private: bool,
initiator: bool,
peer_alias: []const u8,
// https://github.com/lightningnetwork/lnd/blob/d930dcec/channeldb/channel.go#L616-L644
//chan_status_flag: ChannelStatus
//local_constraints, remote_constraints, pending_htlcs
},
};
pub const PendingList = struct {
total_limbo_balance: i64, // balance in satoshis encumbered in pending channels
pending_open_channels: []struct {
channel: PendingChannel,
commit_fee: i64,
funding_expiry_blocks: i32,
},
pending_force_closing_channels: []struct {
channel: PendingChannel,
closing_txid: []const u8,
limbo_balance: i64,
maturity_height: u32,
blocks_til_maturity: i32, // negative indicates n blocks since maturity
recovered_balance: i64, // total funds successfully recovered from this channel
// pending_htlcs, anchor
},
waiting_close_channels: []struct { // waiting for closing tx confirmation
channel: PendingChannel,
limbo_balance: i64,
closing_txid: []const u8,
// commitments?
},
};
pub const PendingChannel = struct {
remote_node_pub: []const u8,
channel_point: []const u8,
capacity: i64,
local_balance: i64,
remote_balance: i64,
private: bool,
// local_chan_reserve_sat, remote_chan_reserve_sat, initiator, chan_status_flags, memo
};
pub const WalletBalance = struct {
total_balance: i64,
confirmed_balance: i64,
unconfirmed_balance: i64,
locked_balance: i64, // output leases
reserved_balance_anchor_chan: i64, // for fee bumps
};
pub const WalletStatus = struct {
state: enum(u8) {
NON_EXISTING = 0, // uninitialized
LOCKED = 1, // requires password to unlocked
UNLOCKED = 2, // RPC isn't ready
RPC_ACTIVE = 3, // lnd server active but not ready for calls yet
SERVER_ACTIVE = 4, // ready to accept calls
WAITING_TO_START = 255,
},
};

@ -21,6 +21,7 @@ const screen = @import("../ui/screen.zig");
const types = @import("../types.zig");
const SysService = @import("SysService.zig");
const bitcoindrpc = @import("bitcoindrpc.zig");
const lndhttp = @import("../lndhttp.zig");
const logger = std.log.scoped(.daemon);
@ -55,6 +56,10 @@ wpa_save_config_on_connected: bool = false,
want_bitcoind_report: bool,
bitcoin_timer: time.Timer,
bitcoin_report_interval: u64 = 1 * time.ns_per_min,
// lightning flags
want_lnd_report: bool,
lnd_timer: time.Timer,
lnd_report_interval: u64 = 1 * time.ns_per_min,
/// system services actively managed by the daemon.
/// these are stop'ed during poweroff and their shutdown progress sent to ngui.
@ -90,6 +95,9 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer,
// report bitcoind status immediately on start
.want_bitcoind_report = true,
.bitcoin_timer = try time.Timer.start(),
// report lightning status immediately on start
.want_lnd_report = true,
.lnd_timer = try time.Timer.start(),
};
}
@ -285,6 +293,14 @@ fn mainThreadLoopCycle(self: *Daemon) !void {
logger.err("sendBitcoinReport: {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});
}
}
}
/// comm thread entry point: reads messages sent from ngui and acts accordinly.
@ -543,6 +559,148 @@ fn sendBitcoindReport(self: *Daemon) !void {
try comm.write(self.allocator, self.uiwriter, .{ .bitcoind_report = btcrep });
}
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",
});
defer client.deinit();
const info = try client.call(.getinfo, {});
defer info.deinit();
const feerep = try client.call(.feereport, {});
defer feerep.deinit();
const chanlist = try client.call(.listchannels, .{ .peer_alias_lookup = true });
defer chanlist.deinit();
const pending = try client.call(.pendingchannels, {});
defer pending.deinit();
var lndrep = comm.Message.LightningReport{
.version = info.value.version,
.pubkey = info.value.identity_pubkey,
.alias = info.value.alias,
.npeers = info.value.num_peers,
.height = info.value.block_height,
.hash = info.value.block_hash,
.sync = .{
.chain = info.value.synced_to_chain,
.graph = info.value.synced_to_graph,
},
.uris = &.{}, // TODO: dedup info.uris
.totalbalance = .{
.local = 0, // available; computed below
.remote = 0, // available; computed below
.unsettled = 0, // computed below
.pending = pending.value.total_limbo_balance,
},
.totalfees = .{
.day = feerep.value.day_fee_sum,
.week = feerep.value.week_fee_sum,
.month = feerep.value.month_fee_sum,
},
.channels = undefined, // populated below
};
var feemap = std.StringHashMap(struct { base: i64, ppm: i64 }).init(self.allocator);
defer feemap.deinit();
for (feerep.value.channel_fees) |item| {
try feemap.put(item.chan_id, .{ .base = item.base_fee_msat, .ppm = item.fee_per_mil });
}
var channels = std.ArrayList(@typeInfo(@TypeOf(lndrep.channels)).Pointer.child).init(self.allocator);
defer channels.deinit();
for (pending.value.pending_open_channels) |item| {
try channels.append(.{
.id = null,
.state = .pending_open,
.private = item.channel.private,
.point = item.channel.channel_point,
.closetxid = null,
.peer_pubkey = item.channel.remote_node_pub,
.peer_alias = "", // TODO: a cached getnodeinfo?
.capacity = item.channel.capacity,
.balance = .{
.local = item.channel.local_balance,
.remote = item.channel.remote_balance,
.unsettled = 0,
.limbo = 0,
},
.totalsats = .{ .sent = 0, .received = 0 },
.fees = .{ .base = 0, .ppm = 0 },
});
}
for (pending.value.waiting_close_channels) |item| {
try channels.append(.{
.id = null,
.state = .pending_close,
.private = item.channel.private,
.point = item.channel.channel_point,
.closetxid = item.closing_txid,
.peer_pubkey = item.channel.remote_node_pub,
.peer_alias = "", // TODO: a cached getnodeinfo?
.capacity = item.channel.capacity,
.balance = .{
.local = item.channel.local_balance,
.remote = item.channel.remote_balance,
.unsettled = 0,
.limbo = item.limbo_balance,
},
.totalsats = .{ .sent = 0, .received = 0 },
.fees = .{ .base = 0, .ppm = 0 },
});
}
for (pending.value.pending_force_closing_channels) |item| {
try channels.append(.{
.id = null,
.state = .pending_close,
.private = item.channel.private,
.point = item.channel.channel_point,
.closetxid = item.closing_txid,
.peer_pubkey = item.channel.remote_node_pub,
.peer_alias = "", // TODO: a cached getnodeinfo?
.capacity = item.channel.capacity,
.balance = .{
.local = item.channel.local_balance,
.remote = item.channel.remote_balance,
.unsettled = 0,
.limbo = item.limbo_balance,
},
.totalsats = .{ .sent = 0, .received = 0 },
.fees = .{ .base = 0, .ppm = 0 },
});
}
for (chanlist.value.channels) |ch| {
lndrep.totalbalance.local += ch.local_balance;
lndrep.totalbalance.remote += ch.remote_balance;
lndrep.totalbalance.unsettled += ch.unsettled_balance;
try channels.append(.{
.id = ch.chan_id,
.state = if (ch.active) .active else .inactive,
.private = ch.private,
.point = ch.channel_point,
.closetxid = null,
.peer_pubkey = ch.remote_pubkey,
.peer_alias = ch.peer_alias,
.capacity = ch.capacity,
.balance = .{
.local = ch.local_balance,
.remote = ch.remote_balance,
.unsettled = ch.unsettled_balance,
.limbo = 0,
},
.totalsats = .{
.sent = ch.total_satoshis_sent,
.received = ch.total_satoshis_received,
},
.fees = if (feemap.get(ch.chan_id)) |v| .{ .base = v.base, .ppm = v.ppm } else .{ .base = 0, .ppm = 0 },
});
}
lndrep.channels = channels.items;
try comm.write(self.allocator, self.uiwriter, .{ .lightning_report = lndrep });
}
test "start-stop" {
const t = std.testing;
@ -550,6 +708,7 @@ test "start-stop" {
var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null");
daemon.want_network_report = false;
daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;
try t.expect(daemon.state == .stopped);
try daemon.start();
@ -594,6 +753,7 @@ test "start-poweroff" {
var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null");
daemon.want_network_report = false;
daemon.want_bitcoind_report = false;
daemon.want_lnd_report = false;
defer {
daemon.deinit();
gui_stdin.close();

@ -0,0 +1,43 @@
const std = @import("std");
const lndhttp = @import("lndhttp");
pub fn main() !void {
var gpa_state = std.heap.GeneralPurposeAllocator(.{}){};
defer if (gpa_state.deinit() == .leak) {
std.debug.print("memory leaks detected!", .{});
};
const gpa = gpa_state.allocator();
var client = try lndhttp.Client.init(.{
.allocator = gpa,
.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(.getinfo, {});
defer res.deinit();
std.debug.print("{any}\n", .{res.value});
}
//{
// const res = try client.call(.getnetworkinfo, {});
// defer res.deinit();
// std.debug.print("{any}\n", .{res.value});
//}
//{
// const res = try client.call(.listchannels, .{ .peer_alias_lookup = false });
// defer res.deinit();
// 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});
//}
}

@ -79,3 +79,27 @@ pub const StringList = struct {
return self.l.items;
}
};
pub fn Deinitable(comptime T: type) type {
return struct {
value: T,
arena: *std.heap.ArenaAllocator,
const Self = @This();
pub fn init(allocator: std.mem.Allocator) !Self {
var res = Self{
.arena = try allocator.create(std.heap.ArenaAllocator),
.value = undefined,
};
res.arena.* = std.heap.ArenaAllocator.init(allocator);
return res;
}
pub fn deinit(self: Self) void {
const allocator = self.arena.child_allocator;
self.arena.deinit();
allocator.destroy(self.arena);
}
};
}