180 lines
5.0 KiB
Go
180 lines
5.0 KiB
Go
// Copyright (c) 2026 Sławomir Koszewski. All rights reserved.
|
|
// Use of this source code is governed by the MIT License
|
|
// that can be found in the LICENSE file.
|
|
|
|
package miab
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"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. Any empty parameter is filled from environment variables:
|
|
//
|
|
// host: MIAB_HOST or hostname parsed from MAILINABOX_BASE_URL
|
|
// username: MIAB_USERNAME or MAILINABOX_EMAIL
|
|
// password: MIAB_PASSWORD or MAILINABOX_PASSWORD
|
|
//
|
|
// Returns an error if a value is still empty after the env lookup.
|
|
func NewClient(host, username, password string) (*Client, error) {
|
|
if host == "" {
|
|
host = os.Getenv("MIAB_HOST")
|
|
if host == "" {
|
|
if base := os.Getenv("MAILINABOX_BASE_URL"); base != "" {
|
|
if u, err := url.Parse(base); err == nil {
|
|
host = u.Hostname()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if username == "" {
|
|
username = os.Getenv("MIAB_USERNAME")
|
|
if username == "" {
|
|
username = os.Getenv("MAILINABOX_EMAIL")
|
|
}
|
|
}
|
|
if password == "" {
|
|
password = os.Getenv("MIAB_PASSWORD")
|
|
if password == "" {
|
|
password = os.Getenv("MAILINABOX_PASSWORD")
|
|
}
|
|
}
|
|
|
|
var missing []string
|
|
if host == "" {
|
|
missing = append(missing, "MIAB_HOST or MAILINABOX_BASE_URL")
|
|
}
|
|
if username == "" {
|
|
missing = append(missing, "MIAB_USERNAME or MAILINABOX_EMAIL")
|
|
}
|
|
if password == "" {
|
|
missing = append(missing, "MIAB_PASSWORD or MAILINABOX_PASSWORD")
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("miab: missing required environment variables: %v", missing)
|
|
}
|
|
|
|
return &Client{
|
|
host: host,
|
|
username: username,
|
|
password: password,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|