do-dyn

Unnamed repository; edit this file 'description' to name the repository.
git clone git://code.mfashby.net:/do-dyn
Log | Files | Refs | README

commit 4776eacee5559a48161032254216c833f4a60b15
Author: Martin Ashby <martin@ashbysoft.com>
Date:   Thu, 25 Jan 2024 23:45:29 +0000

Initial

Diffstat:
A.gitignore | 3+++
AREADME.md | 9+++++++++
Abuild.zig | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abuild.zig.zon | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.zig | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/root.zig | 10++++++++++
6 files changed, 295 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +dotoken +zig-cache +zig-out diff --git a/README.md b/README.md @@ -0,0 +1,8 @@ +# Little program to update my digital ocean records dynamically + +Usage: +do-dyn [TOKEN] [DOMAIN] ([TYPE] [NAME])... + +This program updates digital ocean DNS records for DOMAIN (specified by [TYPE]/[NAME] pairs), by inspecting the login page of the local vodafone router to check the WAN IP. + +Yes, it's very specific. +\ No newline at end of file diff --git a/build.zig b/build.zig @@ -0,0 +1,91 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const lib = b.addStaticLibrary(.{ + .name = "do-dyn", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = .{ .path = "src/root.zig" }, + .target = target, + .optimize = optimize, + }); + + // This declares intent for the library to be installed into the standard + // location when the user invokes the "install" step (the default step when + // running `zig build`). + b.installArtifact(lib); + + const exe = b.addExecutable(.{ + .name = "do-dyn", + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const lib_unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/root.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_lib_unit_tests.step); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon @@ -0,0 +1,62 @@ +.{ + .name = "do-dyn", + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + + // This field is optional. + // This is currently advisory only; Zig does not yet do anything + // with this value. + //.minimum_zig_version = "0.11.0", + + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // Internet connectivity. + .dependencies = .{ + // See `zig fetch --save <url>` for a command-line interface for adding dependencies. + //.example = .{ + // // When updating this field to a new URL, be sure to delete the corresponding + // // `hash`, otherwise you are communicating that you expect to find the old hash at + // // the new URL. + // .url = "https://example.com/foo.tar.gz", + // + // // This is computed from the file contents of the directory of files that is + // // obtained after fetching `url` and applying the inclusion rules given by + // // `paths`. + // // + // // This field is the source of truth; packages do not come from an `url`; they + // // come from a `hash`. `url` is just one of many possible mirrors for how to + // // obtain a package matching this `hash`. + // // + // // Uses the [multihash](https://multiformats.io/multihash/) format. + // .hash = "...", + // + // // When this is provided, the package is found in a directory relative to the + // // build root. In this case the package's hash is irrelevant and therefore not + // // computed. This field and `url` are mutually exclusive. + // .path = "foo", + //}, + }, + + // Specifies the set of files and directories that are included in this package. + // Only files and directories listed here are included in the `hash` that + // is computed for this package. + // Paths are relative to the build root. Use the empty string (`""`) to refer to + // the build root itself. + // A directory listed here means that all files within, recursively, are included. + .paths = .{ + // This makes *all* files, recursively, included in this package. It is generally + // better to explicitly list the files and directories instead, to insure that + // fetching from tarballs, file system paths, and version control all result + // in the same contents hash. + "", + // For example... + //"build.zig", + //"build.zig.zon", + //"src", + //"LICENSE", + //"README.md", + }, +} diff --git a/src/main.zig b/src/main.zig @@ -0,0 +1,120 @@ +const std = @import("std"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const a = gpa.allocator(); + + // Argument parsing + var ai = std.process.args(); + if (!ai.skip()) return error.Wtf; + const dotoken = ai.next() orelse return error.NoDoToken; + const domain = ai.next() orelse return error.NoDomain; + // then a list of type/name combos to update with our new IP + var toupdate = std.ArrayList(Rec).init(a); + while (ai.next()) |tpe| { + const name = ai.next() orelse return error.InvalidArgs; + try toupdate.append(.{ + .type = tpe, + .name = name, + }); + } + defer toupdate.deinit(); + + // Check my current public IP on the router homepage + + var cli = std.http.Client{ .allocator = a }; + defer cli.deinit(); + + const ip = try getWanIp(&cli); + defer cli.allocator.free(ip); + std.log.info("Found IP address {s}", .{ip}); + + // Now check my digital ocean records and update them if they are out of date + const url = try std.fmt.allocPrint(a, "https://api.digitalocean.com/v2/domains/{s}/records", .{domain}); + defer a.free(url); + const authorization_header_value = try std.fmt.allocPrint(a, "Bearer {s}", .{dotoken}); + defer a.free(authorization_header_value); + var headers = std.http.Headers.init(a); + try headers.append("Authorization", authorization_header_value); + defer headers.deinit(); + var domains = try cli.fetch(a, .{ + .location = .{ .url = url }, + .headers = headers, + }); + defer domains.deinit(); + if (domains.status != std.http.Status.ok) { + return error.HttpStatusError; + } + const bod = domains.body orelse return error.HttpNoBodyError; + var v = try std.json.parseFromSlice(DomainRecordsResp, a, bod, .{ .ignore_unknown_fields = true }); + defer v.deinit(); + const drr = v.value; + for (drr.domain_records) |dr| { + for (toupdate.items) |rec| { + if (std.mem.eql(u8, dr.name, rec.name) and std.mem.eql(u8, dr.type, rec.type)) { + // Now check the value + if (!std.mem.eql(u8, dr.data, ip)) { + std.log.info("record needs updaring type [{s}] name [{s}] data [{s}] -> [{s}]", .{ dr.type, dr.name, dr.data, ip }); + var dr2: DomainRec = dr; + dr2.data = ip; + + // Make a PATCH request... + const url2 = try std.fmt.allocPrint(a, "https://api.digitalocean.com/v2/domains/{s}/records/{}", .{ domain, dr2.id }); + defer a.free(url2); + const payload = try std.json.stringifyAlloc(a, dr2, .{}); + defer a.free(payload); + var res2 = try cli.fetch(a, .{ .location = .{ .url = url2 }, .method = .PATCH, .payload = .{ .string = payload }, .headers = headers }); + defer res2.deinit(); + if (res2.status != std.http.Status.ok) { + return error.HttpStatusError; + } + } + } + } + } +} + +const Rec = struct { + type: []const u8, + name: []const u8, +}; + +const DomainRec = struct { + id: u32, + type: []const u8, + name: []const u8, + data: []const u8, + priority: ?u32, + port: ?u16, + ttl: ?u32, + weight: ?u16, + flags: ?u8, + tag: ?[]const u8, +}; + +const DomainRecordsResp = struct { + domain_records: []DomainRec, +}; + +fn getWanIp(cli: *std.http.Client) ![]const u8 { + // Look for this in the HTML of my router's login page + // not very cross platform, there are no doubt external services that could do this + // in a different way but this doesn't rely on those + // <span id="footer-wanip">84.65.54.66</span> + var resp = try cli.fetch(cli.allocator, .{ + .location = .{ .url = "http://192.168.1.1" }, + }); + defer resp.deinit(); + if (resp.status != std.http.Status.ok) { + return error.HttpStatusError; + } + var bod = resp.body orelse return error.HttpNoBodyError; + const wanstart = "<span id=\"footer-wanip\">"; + const wanend = "</span>"; + const start = (std.mem.indexOf(u8, bod, wanstart) orelse return error.NoWanIpFound) + wanstart.len; + const end = start + (std.mem.indexOf(u8, bod[start..], wanend) orelse return error.NoWanIpFound); + const ip = bod[start..end]; + _ = try std.net.Ip4Address.parse(ip, 0); // Check it's a valid IP... + return try cli.allocator.dupe(u8, ip); +} diff --git a/src/root.zig b/src/root.zig @@ -0,0 +1,10 @@ +const std = @import("std"); +const testing = std.testing; + +export fn add(a: i32, b: i32) i32 { + return a + b; +} + +test "basic add functionality" { + try testing.expect(add(3, 7) == 10); +}