commit 4776eacee5559a48161032254216c833f4a60b15
Author: Martin Ashby <martin@ashbysoft.com>
Date: Thu, 25 Jan 2024 23:45:29 +0000
Initial
Diffstat:
A | .gitignore | | | 3 | +++ |
A | README.md | | | 9 | +++++++++ |
A | build.zig | | | 91 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | build.zig.zon | | | 62 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | src/main.zig | | | 120 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | src/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);
+}