const std = @import("std"); const builtin = @import("builtin"); const Config = @import("../Config.zig"); const Pins = @import("../location/Pins.zig"); const GeoCache = @import("../location/GeoCache.zig"); const Resolver = @import("../location/resolver.zig").Resolver; const log = std.log.scoped(.pin); /// Operator commands for managing IP pins. /// /// A pin is an IP range to location override consulted ahead of GeoLite2. It /// exists because GeoLite2 can be confidently wrong: a corporate WAN address may /// carry `accuracy_radius = 20` while pointing at a city 1400 miles away, and /// because the online fallback only runs when GeoLite2 declines to answer, /// nothing automatic ever corrects it. /// /// After writing the file these commands signal any running server so the change /// takes effect without a restart. The database is mapped and the pins are held /// in memory, so editing files alone would not be noticed. pub fn printUsage(io: std.Io, exe: []const u8) !u8 { var buf: [2048]u8 = undefined; var out = std.Io.File.stdout().writer(io, &buf); const w = &out.interface; const base = std.fs.path.basename(exe); try w.print( \\usage: {s} [command] \\ \\With no command, runs the weather server. \\ \\Commands: \\ pin Override the location for an IP or range \\ unpin Remove an override \\ pins List overrides \\ help Show this message \\ \\Pins take precedence over the GeoLite2 database, which is the point: \\they exist for addresses the database resolves confidently and wrongly. \\ \\Examples: \\ {s} pin 12.94.132.0/24 San Francisco \\ {s} pin 12.94.132.170 Portland, Oregon \\ {s} pin 2001:db8::/32 London \\ {s} unpin 12.94.132.0/24 \\ \\A bare address is treated as a single host (/32 or /128). When ranges \\overlap, the longest prefix wins, so a host pin beats a range pin. \\ , .{ base, base, base, base, base }); try w.flush(); return 0; } /// Joins the remaining argv into a Nominatim-friendly query. /// /// Spaces become `+` because the geocoding path builds a URL query directly, so /// both `pin ... San Francisco` and `pin ... "San Francisco"` work. fn joinLocation(allocator: std.mem.Allocator, parts: []const []const u8) ![]u8 { var list: std.ArrayList(u8) = .empty; errdefer list.deinit(allocator); for (parts, 0..) |part, i| { if (i > 0) try list.append(allocator, '+'); for (part) |ch| try list.append(allocator, if (ch == ' ') '+' else ch); } return list.toOwnedSlice(allocator); } pub fn runPin( allocator: std.mem.Allocator, io: std.Io, cfg: Config, args: []const []const u8, ) !u8 { var err_buf: [512]u8 = undefined; var err_out = std.Io.File.stderr().writer(io, &err_buf); const ew = &err_out.interface; if (args.len < 2) { try ew.writeAll("usage: wttr pin \n"); try ew.flush(); return 2; } const cidr = Pins.parseCidr(args[0]) catch |e| { try ew.print("invalid address or range {s}: {t}\n", .{ args[0], e }); try ew.flush(); return 2; }; const query = try joinLocation(allocator, args[1..]); defer allocator.free(query); // Geocode now rather than storing a bare name, for two reasons: it validates // the location while the operator is present to see the error, and it keeps // the lookup path free of network calls. var geocache = try GeoCache.init(allocator, io, cfg.geocache_file); defer geocache.deinit(); var resolver = Resolver.init(allocator, io, null, &geocache, null); const location = resolver.resolve(query) catch |e| { try ew.print("could not resolve location {s}: {t}\n", .{ query, e }); try ew.flush(); return 1; }; defer location.deinit(); var pins = try Pins.load(allocator, io, cfg.pins_file); defer pins.deinit(); try pins.put(cidr, location.name, location.coords); try pins.save(io, cfg.pins_file); var out_buf: [512]u8 = undefined; var out = std.Io.File.stdout().writer(io, &out_buf); const w = &out.interface; try w.print("pinned {s} -> {s} ({d:.4}, {d:.4})\n", .{ args[0], location.name, location.coords.latitude, location.coords.longitude, }); try w.print("wrote {s}\n", .{cfg.pins_file}); try reportSignal(w, notifyServers(io)); try w.flush(); return 0; } pub fn runUnpin( allocator: std.mem.Allocator, io: std.Io, cfg: Config, args: []const []const u8, ) !u8 { var err_buf: [512]u8 = undefined; var err_out = std.Io.File.stderr().writer(io, &err_buf); const ew = &err_out.interface; if (args.len != 1) { try ew.writeAll("usage: wttr unpin \n"); try ew.flush(); return 2; } const cidr = Pins.parseCidr(args[0]) catch |e| { try ew.print("invalid address or range {s}: {t}\n", .{ args[0], e }); try ew.flush(); return 2; }; var pins = try Pins.load(allocator, io, cfg.pins_file); defer pins.deinit(); if (!pins.remove(cidr)) { try ew.print("no pin found for {s}\n", .{args[0]}); try ew.flush(); return 1; } try pins.save(io, cfg.pins_file); var out_buf: [512]u8 = undefined; var out = std.Io.File.stdout().writer(io, &out_buf); const w = &out.interface; try w.print("removed pin {s}\n", .{args[0]}); try reportSignal(w, notifyServers(io)); try w.flush(); return 0; } pub fn runList(allocator: std.mem.Allocator, io: std.Io, cfg: Config) !u8 { var pins = try Pins.load(allocator, io, cfg.pins_file); defer pins.deinit(); var out_buf: [4096]u8 = undefined; var out = std.Io.File.stdout().writer(io, &out_buf); const w = &out.interface; if (pins.entries.items.len == 0) { try w.print("no pins configured ({s})\n", .{cfg.pins_file}); try w.flush(); return 0; } var cidr_buf: [64]u8 = undefined; for (pins.entries.items) |e| { const text = try Pins.formatCidr( .{ .family = e.family, .network = e.network, .prefix_len = e.prefix_len }, &cidr_buf, ); try w.print("{s: <20} {s} ({d:.4}, {d:.4})\n", .{ text, e.name, e.coords.latitude, e.coords.longitude, }); } try w.flush(); return 0; } fn reportSignal(w: *std.Io.Writer, signaled: usize) !void { switch (signaled) { // Worth saying plainly: the most likely cause is running this outside the // container the server lives in, where the pins file being written is not // the one the server reads. 0 => try w.writeAll( \\signaled 0 running servers (change applies on next start) \\ if the server runs in a container, run this inside it, e.g. \\ docker exec /wttr ... \\ ), 1 => try w.writeAll("signaled 1 running server\n"), else => try w.print("signaled {d} running servers\n", .{signaled}), } } /// Sends SIGHUP to every running server, returning how many were signalled. /// /// Scanning `/proc` rather than tracking a pidfile keeps the server free of /// pidfile lifecycle handling (staleness, crash leftovers, one-per-cache-dir /// assumptions). It also does the right thing inside a container, where the /// server is PID 1 and shares the namespace with `docker exec`. /// /// On a system without `/proc` the directory simply fails to open and the count /// is zero, which the caller reports as "applies on next start". fn notifyServers(io: std.Io) usize { if (!@import("../Signals.zig").supported) return 0; if (builtin.os.tag != .linux) return 0; var dir = std.Io.Dir.cwd().openDir(io, "/proc", .{ .iterate = true }) catch return 0; defer dir.close(io); // Compare against our own name rather than a hardcoded "wttr" so that running // under a different filename (a canary build, a symlinked unit) still works. // `comm` is compared on both sides so the kernel's 15-character truncation // applies identically and cannot cause a spurious mismatch. var self_name_buf: [64]u8 = undefined; const self_name = readComm(io, &dir, "self", &self_name_buf) orelse return 0; const self_pid = std.os.linux.getpid(); var signaled: usize = 0; var it = dir.iterate(); while (it.next(io) catch null) |entry| { if (entry.kind != .directory) continue; const pid = std.fmt.parseInt(std.posix.pid_t, entry.name, 10) catch continue; if (pid == self_pid) continue; var name_buf: [64]u8 = undefined; const name = readComm(io, &dir, entry.name, &name_buf) orelse continue; if (!std.mem.eql(u8, name, self_name)) continue; // Only the server reloads on SIGHUP; another command has nothing to // reload. Skipping them keeps this from depending on those processes // ignoring the signal. if (!isServing(io, &dir, entry.name)) continue; std.posix.kill(pid, .HUP) catch |err| { log.debug("could not signal pid {d}: {t}", .{ pid, err }); continue; }; signaled += 1; } return signaled; } /// Reads `/proc//comm` into `buf`, returning the trimmed name. fn readComm(io: std.Io, proc_dir: *std.Io.Dir, pid_name: []const u8, buf: []u8) ?[]const u8 { var path_buf: [64]u8 = undefined; const path = std.fmt.bufPrint(&path_buf, "{s}/comm", .{pid_name}) catch return null; const file = proc_dir.openFile(io, path, .{}) catch return null; defer file.close(io); // Read streaming rather than via `readFileAlloc`: procfs reports `st_size` // as 0, so anything that sizes the read from the file's length reads nothing // and every process looks nameless. const n = file.readStreaming(io, &.{buf}) catch return null; if (n == 0) return null; return std.mem.trim(u8, buf[0..n], " \n"); } /// Whether a process is running the server rather than a command. /// /// The server takes no arguments; every command has a subcommand as its first. fn isServing(io: std.Io, proc_dir: *std.Io.Dir, pid_name: []const u8) bool { var path_buf: [64]u8 = undefined; const path = std.fmt.bufPrint(&path_buf, "{s}/cmdline", .{pid_name}) catch return false; const file = proc_dir.openFile(io, path, .{}) catch return false; defer file.close(io); var buf: [4096]u8 = undefined; const n = file.readStreaming(io, &.{&buf}) catch return false; return cmdlineIsBare(buf[0..n]); } /// Whether a `/proc//cmdline` payload holds exactly one argument. /// /// Split from `isServing` so the parsing is testable without a live process. /// `cmdline` is NUL-separated with a trailing NUL after the final argument, so a /// bare invocation is one entry and anything with a subcommand has two or more. fn cmdlineIsBare(raw: []const u8) bool { if (raw.len == 0) return false; const args = std.mem.trimEnd(u8, raw, "\x00"); if (args.len == 0) return false; return std.mem.indexOfScalar(u8, args, 0) == null; } test "joinLocation joins argv with plus and converts spaces" { const allocator = std.testing.allocator; const a = try joinLocation(allocator, &.{ "San", "Francisco" }); defer allocator.free(a); try std.testing.expectEqualStrings("San+Francisco", a); // A quoted argument arrives as one part containing a space. const b = try joinLocation(allocator, &.{"San Francisco"}); defer allocator.free(b); try std.testing.expectEqualStrings("San+Francisco", b); const c = try joinLocation(allocator, &.{ "Portland,", "Oregon" }); defer allocator.free(c); try std.testing.expectEqualStrings("Portland,+Oregon", c); } test "joinLocation on a single word is unchanged" { const allocator = std.testing.allocator; const s = try joinLocation(allocator, &.{"London"}); defer allocator.free(s); try std.testing.expectEqualStrings("London", s); } test "cmdlineIsBare: a bare invocation is the server" { try std.testing.expect(cmdlineIsBare("/wttr\x00")); // Some kernels omit the trailing NUL on short reads. try std.testing.expect(cmdlineIsBare("/wttr")); try std.testing.expect(cmdlineIsBare("/usr/local/bin/wttr\x00")); } test "cmdlineIsBare: anything with a subcommand is not the server" { try std.testing.expect(!cmdlineIsBare("/wttr\x00pin\x00")); try std.testing.expect(!cmdlineIsBare("/wttr\x00pins\x00")); try std.testing.expect(!cmdlineIsBare("/wttr\x00pin\x0012.0.0.0/8\x00London\x00")); // Trailing NULs must not hide a second argument. try std.testing.expect(!cmdlineIsBare("/wttr\x00help\x00\x00\x00")); } test "cmdlineIsBare: empty or all-NUL input is not a server" { try std.testing.expect(!cmdlineIsBare("")); try std.testing.expect(!cmdlineIsBare("\x00")); try std.testing.expect(!cmdlineIsBare("\x00\x00\x00")); }