mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-28 01:06:50 +09:00
_acme-challenge is CNAMEd once to an acme-dns registration, so headscale needs no DNS credentials. Node certificates need one registration per name; CNAME chains are followed and writes never evict a live value. Updates #2137
330 lines
8.6 KiB
Go
330 lines
8.6 KiB
Go
package dns
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
gigahost "github.com/kradalby/libdns-gigahost"
|
|
"github.com/libdns/acmedns"
|
|
"github.com/libdns/cloudflare"
|
|
"github.com/libdns/hetzner/v2"
|
|
"github.com/libdns/libdns"
|
|
"github.com/libdns/powerdns"
|
|
"github.com/libdns/rfc2136"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func noEnv(string) (any, bool) { return nil, false }
|
|
|
|
func TestNewProvider(t *testing.T) {
|
|
secret := filepath.Join(t.TempDir(), "token")
|
|
require.NoError(t, os.WriteFile(secret, []byte("from-file\n"), 0o600))
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
settings map[string]any
|
|
want Provider
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "cloudflare from settings",
|
|
provider: "cloudflare",
|
|
settings: map[string]any{"name": "cloudflare", "api_token": "tok"},
|
|
want: &cloudflare.Provider{APIToken: "tok"},
|
|
},
|
|
{
|
|
name: "name is case insensitive",
|
|
provider: "CloudFlare",
|
|
settings: map[string]any{"api_token": "tok"},
|
|
want: &cloudflare.Provider{APIToken: "tok"},
|
|
},
|
|
{
|
|
name: "rfc2136",
|
|
provider: "rfc2136",
|
|
settings: map[string]any{
|
|
"server": "ns1.example.com:53",
|
|
"key_name": "headscale",
|
|
"key_alg": "hmac-sha256",
|
|
"key": "c2VjcmV0",
|
|
},
|
|
want: &rfc2136.Provider{
|
|
Server: "ns1.example.com:53",
|
|
KeyName: "headscale",
|
|
KeyAlg: "hmac-sha256",
|
|
Key: "c2VjcmV0",
|
|
},
|
|
},
|
|
{
|
|
name: "secret from path",
|
|
provider: "cloudflare",
|
|
settings: map[string]any{"api_token_path": secret},
|
|
want: &cloudflare.Provider{APIToken: "from-file"},
|
|
},
|
|
{
|
|
name: "value and path both set",
|
|
provider: "cloudflare",
|
|
settings: map[string]any{"api_token": "tok", "api_token_path": secret},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
{
|
|
name: "unreadable path",
|
|
provider: "cloudflare",
|
|
settings: map[string]any{"api_token_path": filepath.Join(t.TempDir(), "missing")},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
{
|
|
name: "unknown key",
|
|
provider: "cloudflare",
|
|
settings: map[string]any{"auth_token": "tok"},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
{
|
|
name: "unknown provider",
|
|
provider: "route53",
|
|
wantErr: ErrUnknownProvider,
|
|
},
|
|
{
|
|
name: "gigahost",
|
|
provider: "gigahost",
|
|
settings: map[string]any{"api_key_path": secret},
|
|
want: &gigahost.Provider{APIKey: "from-file"},
|
|
},
|
|
{
|
|
name: "hetzner",
|
|
provider: "hetzner",
|
|
settings: map[string]any{"api_token_path": secret},
|
|
want: &hetzner.Provider{APIToken: "from-file"},
|
|
},
|
|
{
|
|
name: "powerdns",
|
|
provider: "powerdns",
|
|
settings: map[string]any{"server_url": "https://pdns.example.com", "api_token": "tok"},
|
|
want: &powerdns.Provider{ServerURL: "https://pdns.example.com", APIToken: "tok"},
|
|
},
|
|
{
|
|
name: "acmedns",
|
|
provider: "acmedns",
|
|
settings: map[string]any{
|
|
"username": "user",
|
|
"password_path": secret,
|
|
"subdomain": "reg",
|
|
"server_url": "https://auth.acme-dns.io",
|
|
},
|
|
want: &acmeDNS{
|
|
Username: "user",
|
|
Password: "from-file",
|
|
Subdomain: "reg",
|
|
ServerURL: "https://auth.acme-dns.io",
|
|
served: map[string][2]acmeDNSValue{},
|
|
},
|
|
},
|
|
{
|
|
name: "acmedns per domain",
|
|
provider: "acmedns",
|
|
settings: map[string]any{"config": map[string]any{
|
|
"node.ts.example.com": map[string]any{
|
|
"username": "user",
|
|
"password": "pass",
|
|
"subdomain": "reg",
|
|
"server_url": "https://auth.acme-dns.io",
|
|
},
|
|
}},
|
|
want: &acmeDNS{
|
|
Configs: map[string]acmedns.DomainConfig{
|
|
"node.ts.example.com": {
|
|
Username: "user",
|
|
Password: "pass",
|
|
Subdomain: "reg",
|
|
ServerURL: "https://auth.acme-dns.io",
|
|
},
|
|
},
|
|
served: map[string][2]acmeDNSValue{},
|
|
},
|
|
},
|
|
{
|
|
name: "acmedns server_url without scheme",
|
|
provider: "acmedns",
|
|
settings: map[string]any{
|
|
"username": "user",
|
|
"password": "pass",
|
|
"subdomain": "reg",
|
|
"server_url": "auth.acme-dns.io",
|
|
},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
{
|
|
name: "acmedns without subdomain",
|
|
provider: "acmedns",
|
|
settings: map[string]any{
|
|
"username": "user",
|
|
"password": "pass",
|
|
"server_url": "https://auth.acme-dns.io",
|
|
},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
{
|
|
name: "only a name",
|
|
provider: "gigahost",
|
|
settings: map[string]any{"name": "gigahost"},
|
|
wantErr: ErrProviderConfig,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := NewProvider(tt.provider, tt.settings, noEnv)
|
|
if tt.wantErr != nil {
|
|
require.ErrorIs(t, err, tt.wantErr)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// fakeAcmeDNS is an acme-dns server with one registration, reg. It has two
|
|
// rows, and an update overwrites the one stamped with the earlier second.
|
|
// acme-dns overwrites either on a tie; the fake picks the one written
|
|
// later, which a writer assuming first in, first out gets wrong.
|
|
type fakeAcmeDNS struct {
|
|
*httptest.Server
|
|
|
|
mu sync.Mutex
|
|
writes int
|
|
rows [2]fakeAcmeDNSRow
|
|
}
|
|
|
|
type fakeAcmeDNSRow struct {
|
|
txt string
|
|
stamp int64 // acme-dns's LastUpdate, in whole seconds
|
|
write int // the order of writes, which acme-dns does not keep
|
|
}
|
|
|
|
func newFakeAcmeDNS(t *testing.T) *fakeAcmeDNS {
|
|
t.Helper()
|
|
|
|
f := &fakeAcmeDNS{}
|
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var update struct{ Subdomain, Txt string }
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&update)
|
|
if r.URL.Path != "/update" || err != nil || update.Subdomain != "reg" || r.Header.Get("X-Api-User") != "user" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
i := 0
|
|
if f.rows[1].stamp < f.rows[0].stamp ||
|
|
(f.rows[1].stamp == f.rows[0].stamp && f.rows[1].write > f.rows[0].write) {
|
|
i = 1
|
|
}
|
|
|
|
f.writes++
|
|
f.rows[i] = fakeAcmeDNSRow{txt: update.Txt, stamp: time.Now().Unix(), write: f.writes}
|
|
}))
|
|
t.Cleanup(f.Close)
|
|
|
|
return f
|
|
}
|
|
|
|
func (f *fakeAcmeDNS) registration() map[string]any {
|
|
return map[string]any{
|
|
"username": "user",
|
|
"password": "pass",
|
|
"subdomain": "reg",
|
|
"server_url": f.URL,
|
|
}
|
|
}
|
|
|
|
func (f *fakeAcmeDNS) serving() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
var txts []string
|
|
|
|
for _, row := range f.rows {
|
|
if row.txt != "" {
|
|
txts = append(txts, row.txt)
|
|
}
|
|
}
|
|
|
|
return txts
|
|
}
|
|
|
|
func acmeTXT(node, value string) []libdns.Record {
|
|
return []libdns.Record{libdns.TXT{Name: "_acme-challenge." + node, Text: value}}
|
|
}
|
|
|
|
// A registration serves the two values written last, and deleting is a
|
|
// no-op: an update overwrites the older value even if a CA may still be
|
|
// validating it, however many values were deleted. Two updates within one
|
|
// second carry the same stamp, leaving acme-dns free to overwrite either:
|
|
// after a then b, with a freed, c could overwrite b.
|
|
func TestAcmeDNSRefusesEviction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := newFakeAcmeDNS(t)
|
|
|
|
p, err := NewProvider("acmedns", srv.registration(), noEnv)
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
|
|
a, err := p.AppendRecords(ctx, testZone, acmeTXT("a", "value-a"))
|
|
require.NoError(t, err)
|
|
require.Len(t, a, 1, "certmagic needs the appended record back")
|
|
|
|
b, err := p.AppendRecords(ctx, testZone, acmeTXT("b", "value-b"))
|
|
require.NoError(t, err)
|
|
|
|
_, err = p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
|
|
require.ErrorIs(t, err, ErrTooManyChallenges)
|
|
|
|
// The newer value is free, but an update overwrites the older one.
|
|
_, err = p.DeleteRecords(ctx, testZone, b)
|
|
require.NoError(t, err)
|
|
|
|
_, err = p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
|
|
require.ErrorIs(t, err, ErrTooManyChallenges)
|
|
assert.ElementsMatch(t, []string{"value-a", "value-b"}, srv.serving(), "a refused value reached acme-dns")
|
|
|
|
_, err = p.DeleteRecords(ctx, testZone, a)
|
|
require.NoError(t, err)
|
|
|
|
c, err := p.AppendRecords(ctx, testZone, acmeTXT("c", "value-c"))
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"value-b", "value-c"}, srv.serving())
|
|
|
|
// b is free and now the older value; overwriting it leaves c alone.
|
|
_, err = p.AppendRecords(ctx, testZone, acmeTXT("d", "value-d"))
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"value-c", "value-d"}, srv.serving())
|
|
|
|
// Giving up while waiting to space updates writes nothing.
|
|
_, err = p.DeleteRecords(ctx, testZone, c)
|
|
require.NoError(t, err)
|
|
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
_, err = p.AppendRecords(cancelled, testZone, acmeTXT("e", "value-e"))
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
assert.ElementsMatch(t, []string{"value-c", "value-d"}, srv.serving())
|
|
}
|