From 9b040dfc7b40e81a0311615a8e8b0ee4e2143377 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Sun, 10 May 2026 10:31:06 +0200 Subject: [PATCH] initial commit: add Mail-in-a-Box Go client library and CLI with DNS record management --- README.md | 42 ++++++++++ cmd/miab/main.go | 154 ++++++++++++++++++++++++++++++++++++ dns.go | 130 ++++++++++++++++++++++++++++++ dns_test.go | 201 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + 5 files changed, 530 insertions(+) create mode 100644 README.md create mode 100644 cmd/miab/main.go create mode 100644 dns.go create mode 100644 dns_test.go create mode 100644 go.mod diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ad8656 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# mailinabox-go + +Go client library and CLI for the [Mail-in-a-Box](https://mailinabox.email/) admin DNS API. + +## Library + +```go +import miab "gitea.koszewscy.waw.pl/koszewscy/mailinabox-go" + +c := miab.NewClient("box.example.com", "admin@example.com", "password") + +c.SetRecord("foo.example.com", "TXT", "v=spf1 ~all") +c.AddRecord("foo.example.com", "A", "1.2.3.4") +c.DeleteRecord("foo.example.com", "A", "1.2.3.4") + +records, _ := c.ListRecords("TXT") +``` + +## CLI + +Install: + +```sh +go install gitea.koszewscy.waw.pl/koszewscy/mailinabox-go/cmd/miab@latest +``` + +Credentials via environment variables: + +```sh +export MIAB_HOST=box.example.com +export MIAB_USERNAME=admin@example.com +export MIAB_PASSWORD=password +``` + +Commands: + +```sh +miab list [--type TXT] +miab set --name foo.example.com --type TXT --value "hello" +miab add --name foo.example.com --type A --value 1.2.3.4 +miab delete --name foo.example.com --type TXT [--value "hello"] +``` diff --git a/cmd/miab/main.go b/cmd/miab/main.go new file mode 100644 index 0000000..f12dc26 --- /dev/null +++ b/cmd/miab/main.go @@ -0,0 +1,154 @@ +package main + +import ( + "flag" + "fmt" + "os" + + miab "gitea.koszewscy.waw.pl/koszewscy/mailinabox-go" +) + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: miab [options] + +Commands: + list List custom DNS records + set Set (replace) a DNS record + delete Delete a DNS record + add Add a DNS record + +Credentials are read from environment variables: + MIAB_HOST Mail-in-a-Box hostname (e.g. box.example.com) + MIAB_USERNAME Admin email address + MIAB_PASSWORD Admin password +`) +} + +func mustClient() *miab.Client { + host := os.Getenv("MIAB_HOST") + username := os.Getenv("MIAB_USERNAME") + password := os.Getenv("MIAB_PASSWORD") + + var missing []string + if host == "" { + missing = append(missing, "MIAB_HOST") + } + if username == "" { + missing = append(missing, "MIAB_USERNAME") + } + if password == "" { + missing = append(missing, "MIAB_PASSWORD") + } + if len(missing) > 0 { + fmt.Fprintf(os.Stderr, "miab: missing required environment variables: %v\n", missing) + os.Exit(1) + } + return miab.NewClient(host, username, password) +} + +func main() { + flag.Usage = usage + flag.Parse() + + if flag.NArg() < 1 { + usage() + os.Exit(1) + } + + cmd := flag.Arg(0) + args := flag.Args()[1:] + + switch cmd { + case "list": + runList(args) + case "set": + runSet(args) + case "delete": + runDelete(args) + case "add": + runAdd(args) + default: + fmt.Fprintf(os.Stderr, "miab: unknown command %q\n", cmd) + usage() + os.Exit(1) + } +} + +func runList(args []string) { + fs := flag.NewFlagSet("list", flag.ExitOnError) + recordType := fs.String("type", "", "Filter by record type (e.g. A, TXT, MX)") + fs.Parse(args) + + client := mustClient() + records, err := client.ListRecords(*recordType) + if err != nil { + fmt.Fprintf(os.Stderr, "miab: %v\n", err) + os.Exit(1) + } + + if *recordType != "" { + fmt.Printf("Custom %s records:\n", *recordType) + } else { + fmt.Println("Custom DNS records:") + } + for _, r := range records { + fmt.Printf(" - %s (%s): %s\n", r.Name, r.Type, r.Value) + } +} + +func runSet(args []string) { + fs := flag.NewFlagSet("set", flag.ExitOnError) + name := fs.String("name", "", "Domain name") + recordType := fs.String("type", "", "Record type (e.g. A, TXT)") + value := fs.String("value", "", "Record value") + fs.Parse(args) + + if *name == "" || *recordType == "" || *value == "" { + fmt.Fprintln(os.Stderr, "miab set: --name, --type and --value are required") + os.Exit(1) + } + + client := mustClient() + if err := client.SetRecord(*name, *recordType, *value); err != nil { + fmt.Fprintf(os.Stderr, "miab: %v\n", err) + os.Exit(1) + } +} + +func runDelete(args []string) { + fs := flag.NewFlagSet("delete", flag.ExitOnError) + name := fs.String("name", "", "Domain name") + recordType := fs.String("type", "", "Record type (e.g. A, TXT)") + value := fs.String("value", "", "Record value (optional)") + fs.Parse(args) + + if *name == "" || *recordType == "" { + fmt.Fprintln(os.Stderr, "miab delete: --name and --type are required") + os.Exit(1) + } + + client := mustClient() + if err := client.DeleteRecord(*name, *recordType, *value); err != nil { + fmt.Fprintf(os.Stderr, "miab: %v\n", err) + os.Exit(1) + } +} + +func runAdd(args []string) { + fs := flag.NewFlagSet("add", flag.ExitOnError) + name := fs.String("name", "", "Domain name") + recordType := fs.String("type", "", "Record type (e.g. A, TXT)") + value := fs.String("value", "", "Record value") + fs.Parse(args) + + if *name == "" || *recordType == "" || *value == "" { + fmt.Fprintln(os.Stderr, "miab add: --name, --type and --value are required") + os.Exit(1) + } + + client := mustClient() + if err := client.AddRecord(*name, *recordType, *value); err != nil { + fmt.Fprintf(os.Stderr, "miab: %v\n", err) + os.Exit(1) + } +} diff --git a/dns.go b/dns.go new file mode 100644 index 0000000..0e03ff4 --- /dev/null +++ b/dns.go @@ -0,0 +1,130 @@ +package miab + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client holds connection configuration for the Mail-in-a-Box admin DNS API. +type Client struct { + host string + username string + password string + httpClient *http.Client +} + +// DNSRecord represents a single custom DNS record returned by the MIAB API. +type DNSRecord struct { + Name string `json:"name"` + Type string `json:"type"` + Value string `json:"value"` +} + +// NewClient constructs a Client. host should be the bare hostname (e.g. "box.example.com"). +func NewClient(host, username, password string) *Client { + return &Client{ + host: host, + username: username, + password: password, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// SetRecord replaces all existing records of the given type for name with value (HTTP PUT). +func (c *Client) SetRecord(name, recordType, value string) error { + _, err := c.do(http.MethodPut, c.buildURL(name, recordType), value) + return err +} + +// DeleteRecord removes the record matching name, recordType and value (HTTP DELETE). +// Pass an empty value to remove all records of that type for the name. +func (c *Client) DeleteRecord(name, recordType, value string) error { + _, err := c.do(http.MethodDelete, c.buildURL(name, recordType), value) + return err +} + +// AddRecord adds a record without replacing existing ones (HTTP POST). +func (c *Client) AddRecord(name, recordType, value string) error { + _, err := c.do(http.MethodPost, c.buildURL(name, recordType), value) + return err +} + +// ListRecords returns all custom DNS records. If recordType is non-empty, only +// records of that type are returned. +func (c *Client) ListRecords(recordType string) ([]DNSRecord, error) { + body, err := c.do(http.MethodGet, fmt.Sprintf("https://%s/admin/dns/custom", c.host), "") + if err != nil { + return nil, err + } + + // MIAB returns an array of objects with fields: qname, rtype, value + var raw []struct { + QName string `json:"qname"` + RType string `json:"rtype"` + Value string `json:"value"` + } + if err := json.Unmarshal([]byte(body), &raw); err != nil { + return nil, fmt.Errorf("miab: failed to parse response: %w", err) + } + + var records []DNSRecord + upper := strings.ToUpper(recordType) + for _, r := range raw { + if recordType == "" || r.RType == upper { + records = append(records, DNSRecord{ + Name: r.QName, + Type: r.RType, + Value: r.Value, + }) + } + } + return records, nil +} + +// buildURL mirrors miab.py's get_miab_url: omits the record type segment for "A" records. +func (c *Client) buildURL(name, recordType string) string { + if strings.ToUpper(recordType) == "A" { + return fmt.Sprintf("https://%s/admin/dns/custom/%s", c.host, name) + } + return fmt.Sprintf("https://%s/admin/dns/custom/%s/%s", c.host, name, strings.ToUpper(recordType)) +} + +// do executes an HTTP request with Basic Auth and returns the response body as a string. +func (c *Client) do(method, url, body string) (string, error) { + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return "", fmt.Errorf("miab: failed to create request: %w", err) + } + req.SetBasicAuth(c.username, c.password) + if body != "" { + req.Header.Set("Content-Type", "text/plain") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("miab: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("miab: failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("miab: unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + return string(respBody), nil +} diff --git a/dns_test.go b/dns_test.go new file mode 100644 index 0000000..3ebeb47 --- /dev/null +++ b/dns_test.go @@ -0,0 +1,201 @@ +package miab + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBuildURL(t *testing.T) { + c := NewClient("box.example.com", "user", "pass") + + tests := []struct { + name, recordType, want string + }{ + {"example.com", "A", "https://box.example.com/admin/dns/custom/example.com"}, + {"example.com", "a", "https://box.example.com/admin/dns/custom/example.com"}, + {"example.com", "TXT", "https://box.example.com/admin/dns/custom/example.com/TXT"}, + {"example.com", "txt", "https://box.example.com/admin/dns/custom/example.com/TXT"}, + {"example.com", "MX", "https://box.example.com/admin/dns/custom/example.com/MX"}, + } + + for _, tt := range tests { + got := c.buildURL(tt.name, tt.recordType) + if got != tt.want { + t.Errorf("buildURL(%q, %q) = %q; want %q", tt.name, tt.recordType, got, tt.want) + } + } +} + +func TestSetRecord(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.URL.Path != "/admin/dns/custom/test.example.com/TXT" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + assertBasicAuth(t, r, "user@example.com", "secret") + body, _ := io.ReadAll(r.Body) + if string(body) != "hello" { + t.Errorf("unexpected body: %s", body) + } + fmt.Fprintln(w, "OK") + })) + defer srv.Close() + + c := testClient(srv) + if err := c.SetRecord("test.example.com", "TXT", "hello"); err != nil { + t.Fatal(err) + } +} + +func TestDeleteRecord(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/admin/dns/custom/test.example.com/TXT" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + assertBasicAuth(t, r, "user@example.com", "secret") + body, _ := io.ReadAll(r.Body) + if string(body) != "hello" { + t.Errorf("unexpected body: %s", body) + } + fmt.Fprintln(w, "OK") + })) + defer srv.Close() + + c := testClient(srv) + if err := c.DeleteRecord("test.example.com", "TXT", "hello"); err != nil { + t.Fatal(err) + } +} + +func TestAddRecord(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/admin/dns/custom/test.example.com" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + assertBasicAuth(t, r, "user@example.com", "secret") + fmt.Fprintln(w, "OK") + })) + defer srv.Close() + + c := testClient(srv) + if err := c.AddRecord("test.example.com", "A", "1.2.3.4"); err != nil { + t.Fatal(err) + } +} + +func TestListRecords_all(t *testing.T) { + raw := []map[string]string{ + {"qname": "a.example.com", "rtype": "A", "value": "1.2.3.4"}, + {"qname": "b.example.com", "rtype": "TXT", "value": "v=spf1"}, + } + srv := jsonServer(t, raw) + defer srv.Close() + + c := testClient(srv) + records, err := c.ListRecords("") + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } +} + +func TestListRecords_filtered(t *testing.T) { + raw := []map[string]string{ + {"qname": "a.example.com", "rtype": "A", "value": "1.2.3.4"}, + {"qname": "b.example.com", "rtype": "TXT", "value": "v=spf1"}, + } + srv := jsonServer(t, raw) + defer srv.Close() + + c := testClient(srv) + records, err := c.ListRecords("TXT") + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("expected 1 TXT record, got %d", len(records)) + } + if records[0].Name != "b.example.com" { + t.Errorf("unexpected record name: %s", records[0].Name) + } +} + +func TestErrorResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + })) + defer srv.Close() + + c := testClient(srv) + err := c.SetRecord("test.example.com", "TXT", "val") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 in error, got: %v", err) + } +} + +// testClient builds a Client pointed at the test server's host. +func testClient(srv *httptest.Server) *Client { + c := NewClient(strings.TrimPrefix(srv.URL, "http://"), "user@example.com", "secret") + c.httpClient = srv.Client() + // Override buildURL to use http instead of https for the test server. + // We do this by wrapping the httpClient transport (no-op) and patching + // the host so the URL function produces http:// URLs. + // Simplest approach: override the host to include the scheme directly. + // Since buildURL always prepends "https://", we use a custom transport + // that rewrites the scheme. + c.httpClient.Transport = &schemeRewriter{underlying: http.DefaultTransport, targetURL: srv.URL} + return c +} + +// schemeRewriter is a RoundTripper that rewrites the request URL scheme+host +// to point at the test server, allowing testClient to use the real buildURL logic. +type schemeRewriter struct { + underlying http.RoundTripper + targetURL string // e.g. "http://127.0.0.1:PORT" +} + +func (s *schemeRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + target := strings.TrimPrefix(s.targetURL, "http://") + clone.URL.Scheme = "http" + clone.URL.Host = target + return s.underlying.RoundTrip(clone) +} + +func jsonServer(t *testing.T, payload any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertBasicAuth(t, r, "user@example.com", "secret") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) + })) +} + +func assertBasicAuth(t *testing.T, r *http.Request, username, password string) { + t.Helper() + u, p, ok := r.BasicAuth() + if !ok { + t.Error("expected Basic Auth header") + } + if u != username || p != password { + t.Errorf("auth mismatch: got %s/%s, want %s/%s", u, p, username, password) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c0a398 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.koszewscy.waw.pl/koszewscy/mailinabox-go + +go 1.26