Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
acme-dns
./acme-dns
./acme-dns-mcp
acme-dns.db
acme-dns.log
.vagrant
Expand Down
8 changes: 8 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ builds:
goos:
- linux
- darwin
- id: acme-dns-mcp
main: ./cmd/acme-dns-mcp
binary: acme-dns-mcp
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
checksum:
name_template: 'checksums.txt'
snapshot:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
- Bearer token authentication for admin endpoints
- Store custom DNS records in a database (A, AAAA, NS, TXT, CNAME, MX, SOA, SRV, PTR)
- Comprehensive DNS record validation (type, value, TTL)
- OpenAPI 3.1 specification served at `GET /openapi.json`
- `acme-dns-mcp` binary: stdio MCP server exposing acme-dns as tools for MCP-compatible AI agents
- Changed
- DNS server now serves both challenge records and managed records from the database
- FQDN normalization on ingress for consistency
Expand Down
108 changes: 93 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ For longer explanation of the underlying issue and other proposed solutions, see
- Simplified DNS server, serving your ACME DNS challenges (TXT)
- Custom records (have your required A, AAAA, NS, etc. records served)
- Admin API for managing DNS records with Bearer token authentication
- OpenAPI 3.1 specification served at `GET /openapi.json`
- MCP server binary (`acme-dns-mcp`) exposing acme-dns as structured tools for MCP-compatible AI agents
- HTTP API automatically acquires and uses Let's Encrypt TLS certificate
- Limit /update API endpoint access to specific CIDR mask(s), defined in the /register request
- Supports SQLite & PostgreSQL as DB backends
Expand Down Expand Up @@ -253,6 +255,91 @@ The method can be used to check readiness and/or liveness of the server. It will

`GET /health`

### OpenAPI specification

A static OpenAPI 3.1 document describing every endpoint above (`/register`, `/update`, `/health`, and the admin record CRUD endpoints) is embedded in the binary and served without authentication:

`GET /openapi.json`

Point any OpenAPI-compatible tool (Swagger UI, Postman, code generators, etc.) at this URL to explore or generate a client for the API.

## MCP Server

`cmd/acme-dns-mcp` builds a separate binary, `acme-dns-mcp`, that exposes the acme-dns HTTP API as [Model Context Protocol](https://modelcontextprotocol.io) (MCP) tools over stdio, so MCP-compatible AI agents (Claude Desktop, Claude Code, etc.) can register subdomains, publish ACME challenge records, and manage DNS records on your behalf.

It is a thin, stateless JSON-RPC 2.0 wrapper: every tool call becomes a single HTTP request against a running acme-dns instance (configured via `base_url`) and the HTTP response is translated back into an MCP tool result. It does not talk to the database or DNS server directly.

### Installing

`acme-dns-mcp` ships in the same release archive as `acme-dns` (see [Installation](#installation)). Download the archive for your platform from the [latest release](https://github.com/zpascal/acme-dns/releases/latest), extract it, and move the binary to a directory in your $PATH, for example:

`sudo mv acme-dns-mcp /usr/local/bin`

### Configuring

Configuration is read from a TOML file, then overridden by environment variables. Both are optional — a missing config file is not an error, and any field can be supplied purely through the environment.

- Default file location: `~/.acme-dns-mcp/config.toml`
- Override the file location with `ACMEDNS_MCP_CONFIG=/path/to/config.toml`

```toml
# ~/.acme-dns-mcp/config.toml
base_url = "https://auth.example.org"
admin_token = "your-secret-admin-token-here"
username = "c36f50e8-4632-44f0-83fe-e070fef28a10"
password = "paasword"
```

| TOML key | Environment variable | Used by |
|---------------|-----------------------|-----------------------------------------------------------------------------------|
| `base_url` | `ACMEDNS_BASE_URL` | All tools — the acme-dns instance to talk to |
| `admin_token` | `ACMEDNS_ADMIN_TOKEN` | `list_dns_records`, `create_dns_record`, `update_dns_record`, `delete_dns_record` |
| `username` | `ACMEDNS_USERNAME` | `update_txt_record` |
| `password` | `ACMEDNS_PASSWORD` | `update_txt_record` |

Credentials are only ever read from this configuration — MCP tool arguments never accept a token, username, or password, so a malicious or careless prompt cannot exfiltrate credentials through tool call arguments.

### Registering with an MCP client

Most MCP clients (e.g. Claude Desktop, Claude Code) accept a `command`+`env` style server definition:

```json
{
"mcpServers": {
"acme-dns": {
"command": "/usr/local/bin/acme-dns-mcp",
"env": {
"ACMEDNS_BASE_URL": "https://auth.example.org",
"ACMEDNS_ADMIN_TOKEN": "your-secret-admin-token-here"
}
}
}
}
```

### Available tools

| Tool | Requires | Arguments | Description |
|----------------------|------------------------|-------------------------------------------------------------------|--------------------------------------------------------------------------------|
| `health_check` | — | none | Checks that the acme-dns instance is reachable. |
| `register_subdomain` | — | `allowfrom` (optional array of CIDRs) | Registers a new subdomain and returns credentials, mirroring `POST /register`. |
| `update_txt_record` | `username`, `password` | `subdomain` (required), `txt` (required, exactly 43 characters) | Publishes an ACME challenge TXT value, mirroring `POST /update`. |
| `list_dns_records` | `admin_token` | `type`, `name` (both optional filters) | Lists managed DNS records, mirroring `GET /admin/records`. |
| `create_dns_record` | `admin_token` | `name`, `type`, `value` (required), `ttl` (optional, default 300) | Creates a managed DNS record. |
| `update_dns_record` | `admin_token` | `id`, `name`, `type`, `value` (required), `ttl` (optional) | Updates a managed DNS record by ID. |
| `delete_dns_record` | `admin_token` | `id` (required) | Deletes a managed DNS record by ID. |

Tools that require `admin_token` or `username`/`password` return `{"error": "..."}` (with `isError: true`, see below) instead of making a request when the corresponding configuration is missing.

### Error handling

Every `tools/call` result carries an `isError` flag alongside its content, per the MCP specification:

- **`isError: false`** — the underlying acme-dns API call succeeded (2xx); the content is the API's response.
- **`isError: true`** — the acme-dns API rejected the request (e.g. `invalid_ttl`, `record_not_found`, `unauthorized`) or the required configuration was missing; the content carries the `{"error": "..."}` body so an MCP client can distinguish a failed operation from a successful one, rather than a failure being reported as an apparently-successful result.

A JSON-RPC-level error (the `error` field on the response, separate from `isError`) is only used for protocol-level failures: an unknown tool name, an unreachable acme-dns instance, or a malformed request.

## Self-hosted

You are encouraged to run your own acme-dns instance, because you are effectively authorizing the acme-dns server to act on your behalf in providing the answer to the challenging CA, making the instance able to request (and get issued) a TLS certificate for the domain that has CNAME pointing to it.
Expand All @@ -261,28 +348,19 @@ See the INSTALL section for information on how to do this.

## Installation

1. Install [Go 1.24 or newer](https://golang.org/doc/install).
2. Build acme-dns:

```bash
git clone https://github.com/zpascal/acme-dns
cd acme-dns
export GOPATH=/tmp/acme-dns
go build
```

3. Move the built acme-dns binary to a directory in your $PATH, for example:
1. Download the archive for your platform from the [latest release](https://github.com/zpascal/acme-dns/releases/latest) (e.g. `acme-dns_<version>_linux_amd64.tar.gz`, also available for `darwin_amd64`, `darwin_arm64`, `linux_arm64`, and `linux_386`). Each archive contains both the `acme-dns` server binary and the `acme-dns-mcp` binary (see [MCP Server](#mcp-server)).
2. Extract the archive and move the acme-dns binary to a directory in your $PATH, for example:
`sudo mv acme-dns /usr/local/bin`
4. Edit config.cfg to suit your needs (see [configuration](#configuration)). `acme-dns` will read the configuration file from `/etc/acme-dns/config.cfg` or `./config.cfg`, or a location specified with the `-c` flag.
5. If your system has systemd, you can optionally install acme-dns as a service so that it will start on boot and be tracked by systemd. This also allows us to add the `CAP_NET_BIND_SERVICE` capability so that acme-dns can be run by a user other than root.
3. Edit config.cfg to suit your needs (see [configuration](#configuration)). `acme-dns` will read the configuration file from `/etc/acme-dns/config.cfg` or `./config.cfg`, or a location specified with the `-c` flag.
4. If your system has systemd, you can optionally install acme-dns as a service so that it will start on boot and be tracked by systemd. This also allows us to add the `CAP_NET_BIND_SERVICE` capability so that acme-dns can be run by a user other than root.
1. Make sure that you have moved the configuration file to `/etc/acme-dns/config.cfg` so that acme-dns can access it globally.
2. Move the acme-dns executable from `~/go/bin/acme-dns` to `/usr/local/bin/acme-dns` (Any location will work, just be sure to change `acme-dns.service` to match).
2. Make sure that the acme-dns executable is at `/usr/local/bin/acme-dns` (any location will work, just be sure to change `acme-dns.service` to match).
3. Create a minimal acme-dns user: `sudo adduser --system --gecos "acme-dns Service" --disabled-password --group --home /var/lib/acme-dns acme-dns`.
4. Move the systemd service unit from `acme-dns.service` to `/etc/systemd/system/acme-dns.service`.
5. Reload systemd units: `sudo systemctl daemon-reload`.
6. Enable acme-dns on boot: `sudo systemctl enable acme-dns.service`.
7. Run acme-dns: `sudo systemctl start acme-dns.service`.
6. If you did not install the systemd service, run `acme-dns`. Please note that acme-dns needs to open a privileged port (53, domain), so it needs to be run with elevated privileges.
5. If you did not install the systemd service, run `acme-dns`. Please note that acme-dns needs to open a privileged port (53, domain), so it needs to be run with elevated privileges.

### Upgrading to v1.0.0

Expand Down
10 changes: 10 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"crypto/subtle"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -35,6 +36,9 @@ func buildCORSOptions(origins []string, methods, headers []string) cors.Options
return opts
}

//go:embed openapi.json
var openapiSpec []byte

// toFQDN ensures a DNS name ends with a trailing dot for consistent storage and lookup.
func toFQDN(name string) string {
name = strings.ToLower(name)
Expand Down Expand Up @@ -163,6 +167,12 @@ func healthCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.WriteHeader(http.StatusOK)
}

func serveOpenAPI(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiSpec)
}

// adminRecordRequest is the request body for creating/updating a DNS record
type adminRecordRequest struct {
Name string `json:"name"`
Expand Down
12 changes: 12 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func setupRouter(debug bool, noauth bool) http.Handler {
))
api.POST("/register", webRegisterPost)
api.GET("/health", healthCheck)
api.GET("/openapi.json", serveOpenAPI)
if noauth {
api.POST("/update", noAuth(webUpdatePost))
} else {
Expand Down Expand Up @@ -442,6 +443,17 @@ func TestApiHealthCheck(t *testing.T) {
e.GET("/health").Expect().Status(http.StatusOK)
}

func TestOpenAPIEndpoint(t *testing.T) {
router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
resp := e.GET("/openapi.json").Expect()
resp.Status(http.StatusOK)
resp.Header("Content-Type").Contains("application/json")
resp.JSON().Object().ContainsKey("openapi")
}

func setupAdminRouter(t *testing.T, token string) (*httptest.Server, *httpexpect.Expect) {
t.Helper()
api := httprouter.New()
Expand Down
38 changes: 38 additions & 0 deletions cmd/acme-dns-mcp/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"fmt"
"os"

"github.com/BurntSushi/toml"
)

type mcpConfig struct {
BaseURL string `toml:"base_url"`
AdminToken string `toml:"admin_token"`
Username string `toml:"username"`
Password string `toml:"password"`
}

// loadConfig reads from a TOML file (if path non-empty) or from env vars
func loadConfig(path string) mcpConfig {
var cfg mcpConfig
if path != "" {
if _, err := toml.DecodeFile(path, &cfg); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "acme-dns-mcp: failed to parse config file %s: %v\n", path, err)
}
}
if v := os.Getenv("ACMEDNS_BASE_URL"); v != "" {
cfg.BaseURL = v
}
if v := os.Getenv("ACMEDNS_ADMIN_TOKEN"); v != "" {
cfg.AdminToken = v
}
if v := os.Getenv("ACMEDNS_USERNAME"); v != "" {
cfg.Username = v
}
if v := os.Getenv("ACMEDNS_PASSWORD"); v != "" {
cfg.Password = v
}
return cfg
}
93 changes: 93 additions & 0 deletions cmd/acme-dns-mcp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
)

// maxRPCLineSize bounds a single JSON-RPC request line. bufio.Scanner's default
// (bufio.MaxScanTokenSize, 64KB) is too small for tool calls carrying larger
// payloads (e.g. batches of DNS records) and fails silently when exceeded.
const maxRPCLineSize = 10 * 1024 * 1024

type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
}

type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result,omitempty"`
Error interface{} `json:"error,omitempty"`
}

// handleRequest processes one decoded JSON-RPC request and returns the response to encode.
func handleRequest(cfg mcpConfig, req jsonRPCRequest) jsonRPCResponse {
var resp jsonRPCResponse
resp.JSONRPC = "2.0"
resp.ID = req.ID

switch req.Method {
case "initialize":
resp.Result = map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
"serverInfo": map[string]interface{}{"name": "acme-dns-mcp", "version": "1.0.0"},
}
case "tools/list":
resp.Result = map[string]interface{}{"tools": listTools()}
case "tools/call":
toolName, _ := req.Params["name"].(string)
args, _ := req.Params["arguments"].(map[string]interface{})
if args == nil {
args = map[string]interface{}{}
}
result, isError, err := callTool(cfg, toolName, args)
if err != nil {
resp.Error = map[string]interface{}{"code": -32000, "message": err.Error()}
} else {
resultJSON, _ := json.Marshal(result)
resp.Result = map[string]interface{}{
"content": []map[string]interface{}{
{"type": "text", "text": string(resultJSON)},
},
"isError": isError,
}
}
default:
resp.Error = map[string]interface{}{"code": -32601, "message": fmt.Sprintf("method not found: %s", req.Method)}
}

return resp
}

func main() {
cfgPath := filepath.Join(os.Getenv("HOME"), ".acme-dns-mcp", "config.toml")
if v := os.Getenv("ACMEDNS_MCP_CONFIG"); v != "" {
cfgPath = v
}
cfg := loadConfig(cfgPath)

scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), maxRPCLineSize)
encoder := json.NewEncoder(os.Stdout)

for scanner.Scan() {
line := scanner.Bytes()
var req jsonRPCRequest
if err := json.Unmarshal(line, &req); err != nil {
continue
}
_ = encoder.Encode(handleRequest(cfg, req))
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "acme-dns-mcp: stdin read error: %v\n", err)
os.Exit(1)
}
}
Loading
Loading