DNS: run separate CoreDNS instance for each PCloud env.

Previously shared CoreDNS instance was used to handle all domains. This has multiple downsides, most important which is security. For example DNS-Sec keys of all domains were persisted on the same shared volume. Also key itself was generated by PCloud env-manager as part of bootstrapping new env. Which is counter to the main aspirations of PCloud, that environment internal private data must not leak outside of the environment.

With new approach implemented in this change, environment starts up it’s own CoreDNS and DNS record manager servers. Manager generates dns-sec keys internally and only exposes public information to the outside world. PCloud infrastructure runes another instance of CoreDNS which acts as a proxy service forwarding requests to individual environments based an requested domain.

This simplifies DNS based TLS challenge solvers, as private certificate issuer of each env will point directly to the DNS record manager of the same environment.

Change-Id: Ifb0f36d2a133e3b53da22030cc7d6b9099136b3d
diff --git a/core/dns-api/.gitignore b/core/dns-api/.gitignore
new file mode 100644
index 0000000..d63d804
--- /dev/null
+++ b/core/dns-api/.gitignore
@@ -0,0 +1 @@
+dns-api*
diff --git a/core/dns-api/Dockerfile b/core/dns-api/Dockerfile
new file mode 100644
index 0000000..5a9a894
--- /dev/null
+++ b/core/dns-api/Dockerfile
@@ -0,0 +1,5 @@
+FROM gcr.io/distroless/static:nonroot
+
+ARG TARGETARCH
+
+COPY dns-api_${TARGETARCH} /usr/bin/dns-api
diff --git a/core/dns-api/Makefile b/core/dns-api/Makefile
new file mode 100644
index 0000000..1f127cb
--- /dev/null
+++ b/core/dns-api/Makefile
@@ -0,0 +1,39 @@
+repo_name ?= dtabidze
+podman ?= docker
+ifeq ($(podman), podman)
+manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
+endif
+
+clean:
+	rm -f dns-api*
+
+# TODO(gio): fix go path
+build:
+	/usr/local/go/bin/go build -o dns-api *.go
+
+build_arm64: export CGO_ENABLED=0
+build_arm64: export GO111MODULE=on
+build_arm64: export GOOS=linux
+build_arm64: export GOARCH=arm64
+build_arm64:
+	/usr/local/go/bin/go build -o dns-api_arm64 *.go
+
+build_amd64: export CGO_ENABLED=0
+build_amd64: export GO111MODULE=on
+build_amd64: export GOOS=linux
+build_amd64: export GOARCH=amd64
+build_amd64:
+	/usr/local/go/bin/go build -o dns-api_amd64 *.go
+
+push_arm64: clean build_arm64
+	$(podman) build --platform linux/arm64 --tag=giolekva/dns-api:arm64 .
+	$(podman) push giolekva/dns-api:arm64
+
+push_amd64: clean build_amd64
+	$(podman) build --platform linux/amd64 --tag=giolekva/dns-api:amd64 .
+	$(podman) push giolekva/dns-api:amd64
+
+push: push_arm64 push_amd64
+	$(podman) manifest create giolekva/dns-api:latest giolekva/dns-api:arm64 giolekva/dns-api:amd64
+	$(podman) manifest push giolekva/dns-api:latest $(manifest_dest)
+	$(podman) manifest rm giolekva/dns-api:latest
diff --git a/core/dns-api/fs.go b/core/dns-api/fs.go
new file mode 100644
index 0000000..597a088
--- /dev/null
+++ b/core/dns-api/fs.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+)
+
+type FS interface {
+	Exists(path string) (bool, error)
+	Reader(path string) (io.ReadCloser, error)
+	Writer(path string) (io.WriteCloser, error)
+	Read(path string) (string, error)
+	Write(path string, data string) error
+	AbsolutePath(path string) string
+}
+
+type osFS struct {
+	root string
+}
+
+func (f osFS) AbsolutePath(path string) string {
+	return filepath.Join(f.root, path)
+}
+
+func (f osFS) Exists(path string) (bool, error) {
+	_, err := os.Stat(f.AbsolutePath(path))
+	if err == nil {
+		return true, nil
+	}
+	if errors.Is(err, os.ErrNotExist) {
+		return false, nil
+	}
+	return false, nil // TODO(gio): return err
+}
+
+func (f osFS) Reader(path string) (io.ReadCloser, error) {
+	return os.Open(f.AbsolutePath(path))
+}
+
+func (f osFS) Writer(path string) (io.WriteCloser, error) {
+	return os.Create(f.AbsolutePath(path))
+}
+
+func (f osFS) Read(path string) (string, error) {
+	r, err := f.Reader(path)
+	if err != nil {
+		return "", err
+	}
+	defer r.Close()
+	d, err := io.ReadAll(r)
+	if err != nil {
+		return "", err
+	}
+	return string(d), err
+}
+
+func (f osFS) Write(path string, data string) error {
+	w, err := f.Writer(path)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	_, err = io.WriteString(w, data)
+	return err
+}
diff --git a/core/dns-api/go.mod b/core/dns-api/go.mod
new file mode 100644
index 0000000..7384cb5
--- /dev/null
+++ b/core/dns-api/go.mod
@@ -0,0 +1,25 @@
+module github.com/giolekva/pcloud/core/dns-api
+
+go 1.21.5
+
+require (
+	github.com/Masterminds/sprig/v3 v3.2.3
+	github.com/miekg/dns v1.1.59
+)
+
+require (
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver/v3 v3.2.0 // indirect
+	github.com/google/uuid v1.1.1 // indirect
+	github.com/huandu/xstrings v1.3.3 // indirect
+	github.com/imdario/mergo v0.3.11 // indirect
+	github.com/mitchellh/copystructure v1.0.0 // indirect
+	github.com/mitchellh/reflectwalk v1.0.0 // indirect
+	github.com/shopspring/decimal v1.2.0 // indirect
+	github.com/spf13/cast v1.3.1 // indirect
+	golang.org/x/crypto v0.21.0 // indirect
+	golang.org/x/mod v0.16.0 // indirect
+	golang.org/x/net v0.22.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
+	golang.org/x/tools v0.19.0 // indirect
+)
diff --git a/core/dns-api/go.sum b/core/dns-api/go.sum
new file mode 100644
index 0000000..91887fd
--- /dev/null
+++ b/core/dns-api/go.sum
@@ -0,0 +1,75 @@
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g=
+github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
+github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
+github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
+github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
+github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
+github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
+github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
+github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
+github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
+github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
+github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
+github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
+github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/core/dns-api/init.go b/core/dns-api/init.go
new file mode 100644
index 0000000..0544611
--- /dev/null
+++ b/core/dns-api/init.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"text/template"
+	"time"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/miekg/dns"
+)
+
+const coreDNSConfigTmpl = `
+{{ .zone }}:53 {
+	file {{ .dbFile }} {
+		reload 1s
+	}
+	errors
+	{{- if .dnsSecBasename }}
+	dnssec {
+		key file {{ .dnsSecBasename}}
+	}
+	{{- end }}
+	log
+	health {
+		lameduck 5s
+	}
+	ready
+	cache 30
+	loop
+	reload
+	loadbalance
+}
+`
+
+const recordsDBTmpl = `
+{{- $zone := .zone }}
+{{ $zone }}.   IN SOA ns1.{{ $zone }}. hostmaster.{{ $zone }}. {{ .nowUnix }} 7200 3600 1209600 3600
+{{- range $i, $ns := .nameserverIP }}
+ns{{ add1 $i }}.{{ $zone }}. 10800 IN A {{ $ns }}
+{{- end }}
+{{- range .publicIP }}
+{{ $zone }}. 10800 IN A {{ . }}
+*.{{ $zone }}. 10800 IN A {{ . }}
+*.*.{{ $zone }}. 10800 IN A {{ . }}
+{{- end }}
+*.p.{{ $zone }}. 10800 IN A {{ .privateIP }}
+`
+
+func NewStore(fs FS, config string, db string, zone string, publicIP []string, privateIP string, nameserverIP []string) (RecordStore, string, error) {
+	dnsSec, err := getDNSSecKey(fs, zone)
+	if err != nil {
+		return nil, "", err
+	}
+	if err := fs.Write(dnsSec.Basename+".key", string(dnsSec.Key)); err != nil {
+		return nil, "", err
+	}
+	if err := fs.Write(dnsSec.Basename+".private", string(dnsSec.Private)); err != nil {
+		return nil, "", err
+	}
+	if err := executeTemplate(fs, config, coreDNSConfigTmpl, map[string]any{
+		"zone":           zone,
+		"dbFile":         fs.AbsolutePath(db),
+		"dnsSecBasename": fs.AbsolutePath(dnsSec.Basename),
+	}); err != nil {
+		return nil, "", err
+	}
+	ok, err := fs.Exists(db)
+	if err != nil {
+		return nil, "", err
+	}
+	if !ok {
+		if err := executeTemplate(fs, db, recordsDBTmpl, map[string]any{
+			"zone":         zone,
+			"publicIP":     publicIP,
+			"privateIP":    privateIP,
+			"nameserverIP": nameserverIP,
+			"nowUnix":      NowUnix(),
+		}); err != nil {
+			return nil, "", err
+		}
+	}
+	return &fsRecordStore{zone, publicIP, fs, db}, string(dnsSec.DS), nil
+}
+
+func getDNSSecKey(fs FS, zone string) (DNSSecKey, error) {
+	const configFile = "dns-sec-key.json"
+	ok, err := fs.Exists(configFile)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	if ok {
+		d, err := fs.Read(configFile)
+		if err != nil {
+			return DNSSecKey{}, err
+		}
+		var k DNSSecKey
+		if err := json.Unmarshal([]byte(d), &k); err != nil {
+			return DNSSecKey{}, err
+		}
+		return k, nil
+	}
+	k, err := newDNSSecKey(zone)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	d, err := json.MarshalIndent(k, "", "\t")
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	if err := fs.Write(configFile, string(d)); err != nil {
+		return DNSSecKey{}, err
+	}
+	return k, nil
+}
+
+type DNSSecKey struct {
+	Basename string `json:"basename,omitempty"`
+	Key      []byte `json:"key,omitempty"`
+	Private  []byte `json:"private,omitempty"`
+	DS       []byte `json:"ds,omitempty"`
+}
+
+func newDNSSecKey(zone string) (DNSSecKey, error) {
+	key := &dns.DNSKEY{
+		Hdr:       dns.RR_Header{Name: dns.Fqdn(zone), Class: dns.ClassINET, Ttl: 3600, Rrtype: dns.TypeDNSKEY},
+		Algorithm: dns.ECDSAP256SHA256, Flags: 257, Protocol: 3,
+	}
+	priv, err := key.Generate(256)
+	if err != nil {
+		return DNSSecKey{}, err
+	}
+	return DNSSecKey{
+		Basename: fmt.Sprintf("K%s+%03d+%05d", key.Header().Name, key.Algorithm, key.KeyTag()),
+		Key:      []byte(key.String()),
+		Private:  []byte(key.PrivateKeyString(priv)),
+		DS:       []byte(key.ToDS(dns.SHA256).String()),
+	}, nil
+}
+
+// TODO(gio): not going to work in 15 years?
+// TODO(gio): remove 10 *
+func NowUnix() uint32 {
+	return 10 * uint32(time.Now().Unix())
+}
+
+func executeTemplate(fs FS, path string, contents string, values map[string]any) error {
+	tmpl, err := template.New("tmpl").Funcs(sprig.TxtFuncMap()).Parse(contents)
+	if err != nil {
+		return err
+	}
+	var d strings.Builder
+	if err := tmpl.Execute(&d, values); err != nil {
+		return err
+	}
+	return fs.Write(path, strings.TrimSpace(d.String())+"\n")
+}
diff --git a/core/dns-api/main.go b/core/dns-api/main.go
new file mode 100644
index 0000000..7ab1bb2
--- /dev/null
+++ b/core/dns-api/main.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+	"flag"
+	"strings"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+var rootDir = flag.String("root-dir", "", "Path to generate DNS sec keys")
+var config = flag.String("config", "", "Coredns config file name")
+var db = flag.String("db", "", "DNS records db file name")
+var zone = flag.String("zone", "", "Zone domain")
+var publicIPs = flag.String("public-ip", "", "Comma separated list of public IPs of the pcloud environment")
+var privateIP = flag.String("private-ip", "", "Private IP of the pcloud environment")
+var nameserverIPs = flag.String("nameserver-ip", "", "Comma separated list of nameserver IPs")
+
+func main() {
+	flag.Parse()
+	publicIP := strings.Split(*publicIPs, ",")
+	nameserverIP := strings.Split(*nameserverIPs, ",")
+	fs := osFS{*rootDir}
+	store, ds, err := NewStore(fs, *config, *db, *zone, publicIP, *privateIP, nameserverIP)
+	if err != nil {
+		panic(err)
+	}
+	server := NewServer(*port, *zone, ds, store, nameserverIP)
+	server.Start()
+}
diff --git a/core/dns-api/main_test.go b/core/dns-api/main_test.go
new file mode 100644
index 0000000..d65d52f
--- /dev/null
+++ b/core/dns-api/main_test.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestDNSSecKey(t *testing.T) {
+	k, err := getDNSSecKey(osFS{"/tmp"}, "foo.bar.ge")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(k)
+}
diff --git a/core/dns-api/records_file.go b/core/dns-api/records_file.go
new file mode 100644
index 0000000..6916a5e
--- /dev/null
+++ b/core/dns-api/records_file.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"net"
+	"strings"
+	"sync"
+
+	"github.com/miekg/dns"
+)
+
+type RecordsFile struct {
+	lock sync.Locker
+	rrs  []dns.RR
+}
+
+func NewRecordsFile(r io.Reader) (*RecordsFile, error) {
+	rrs := make([]dns.RR, 0)
+	p := dns.NewZoneParser(r, "", "")
+	p.SetIncludeAllowed(false)
+	for {
+		if rr, ok := p.Next(); ok {
+			rrs = append(rrs, rr)
+		} else {
+			if err := p.Err(); err != nil {
+				return nil, err
+			}
+			break
+		}
+	}
+	return &RecordsFile{&sync.Mutex{}, rrs}, nil
+}
+
+func (z *RecordsFile) DeleteTxtRecord(name, value string) {
+	z.lock.Lock()
+	defer z.lock.Unlock()
+	fmt.Printf("%s %s\n", name, value)
+	for i, rr := range z.rrs {
+		fmt.Printf("%+v\n", rr)
+		if txt, ok := rr.(*dns.TXT); ok {
+			fmt.Printf("%+v\n", txt)
+			if txt.Hdr.Name == name && strings.Join(txt.Txt, "") == value {
+				z.rrs = append(z.rrs[:i], z.rrs[i+1:]...)
+			}
+		}
+	}
+}
+
+// func (z *RecordsFile) DeleteRecordsFor(name string) {
+// 	z.lock.Lock()
+// 	defer z.lock.Unlock()
+// 	rrs := make([]dns.RR, 0)
+// 	for _, rr := range z.rrs {
+// 		if rr.Header().Name != name {
+// 			rrs = append(rrs, rr)
+// 		}
+// 	}
+// 	z.rrs = rrs
+// }
+
+func (z *RecordsFile) CreateOrReplaceTxtRecord(name, value string) {
+	z.lock.Lock()
+	defer z.lock.Unlock()
+	for i, rr := range z.rrs {
+		if txt, ok := rr.(*dns.TXT); ok {
+			if txt.Hdr.Name == name && strings.Join(txt.Txt, "") == value {
+				txt.Txt = []string{value}
+				z.rrs = append(z.rrs[:i], z.rrs[i+1:]...)
+				z.rrs = append(z.rrs, txt)
+				return
+			}
+		}
+	}
+	z.rrs = append(z.rrs, &dns.TXT{
+		Hdr: dns.RR_Header{
+			Name:   name,
+			Rrtype: dns.TypeTXT,
+			Class:  dns.ClassINET,
+			Ttl:    300,
+		},
+		Txt: []string{value},
+	})
+}
+
+func (z *RecordsFile) CreateARecord(name, value string) {
+	z.lock.Lock()
+	defer z.lock.Unlock()
+	z.rrs = append(z.rrs, &dns.A{
+		Hdr: dns.RR_Header{
+			Name:   name,
+			Rrtype: dns.TypeA,
+			Class:  dns.ClassINET,
+			Ttl:    300,
+		},
+		A: net.ParseIP(value),
+	})
+}
+
+func (z *RecordsFile) Write(w io.Writer) error {
+	z.lock.Lock()
+	defer z.lock.Unlock()
+	for _, rr := range z.rrs {
+		if soa, ok := rr.(*dns.SOA); ok {
+			soa.Serial = NowUnix()
+		}
+		if _, err := fmt.Fprintf(w, "%s\n", rr.String()); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/core/dns-api/server.go b/core/dns-api/server.go
new file mode 100644
index 0000000..37db8ff
--- /dev/null
+++ b/core/dns-api/server.go
@@ -0,0 +1,90 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+type Server struct {
+	s          *http.Server
+	m          *http.ServeMux
+	store      RecordStore
+	zone       string
+	ds         string
+	nameserver []string
+}
+
+func NewServer(port int, zone string, ds string, store RecordStore, nameserver []string) *Server {
+	m := http.NewServeMux()
+	s := &Server{
+		s: &http.Server{
+			Addr:    fmt.Sprintf(":%d", port),
+			Handler: m,
+		},
+		m:          m,
+		store:      store,
+		zone:       zone,
+		ds:         ds,
+		nameserver: nameserver,
+	}
+	m.HandleFunc("/records-to-publish", s.recordsToPublish)
+	m.HandleFunc("/create-txt-record", s.createTxtRecord)
+	m.HandleFunc("/delete-txt-record", s.deleteTxtRecord)
+	return s
+}
+
+func (s *Server) Start() error {
+	return s.s.ListenAndServe()
+}
+
+type record struct {
+	Domain string `json:"domain,omitempty"`
+	Entry  string `json:"entry,omitempty"`
+	Text   string `json:"text,omitempty"`
+}
+
+func (s *Server) recordsToPublish(w http.ResponseWriter, r *http.Request) {
+	subdomain := strings.Split(s.zone, ".")[0]
+	if _, err := fmt.Fprintln(w, s.ds); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for i, ip := range s.nameserver {
+		if _, err := fmt.Fprintf(w, "ns%d.%s. 10800 IN A %s\n", i+1, s.zone, ip); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		if _, err := fmt.Fprintf(w, "%s 10800 IN NS ns%d.%s.\n", subdomain, i+1, s.zone); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+	}
+}
+
+func (s *Server) createTxtRecord(w http.ResponseWriter, r *http.Request) {
+	var req record
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	fmt.Printf("CREATE: %+v\n", req)
+	if err := s.store.Add(req.Entry, req.Text); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) deleteTxtRecord(w http.ResponseWriter, r *http.Request) {
+	var req record
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	fmt.Printf("DELETE: %+v\n", req)
+	if err := s.store.Delete(req.Entry, req.Text); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
diff --git a/core/dns-api/store.go b/core/dns-api/store.go
new file mode 100644
index 0000000..6d0a7c1
--- /dev/null
+++ b/core/dns-api/store.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+	"fmt"
+)
+
+type RecordStore interface {
+	Add(entry, txt string) error
+	Delete(entry, txt string) error
+}
+
+type fsRecordStore struct {
+	zone     string
+	publicIP []string
+	fs       FS
+	db       string
+}
+
+func (s *fsRecordStore) read() (*RecordsFile, error) {
+	r, err := s.fs.Reader(s.db)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	return NewRecordsFile(r)
+}
+func (s *fsRecordStore) write(z *RecordsFile) error {
+	w, err := s.fs.Writer(s.db)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	return z.Write(w)
+}
+
+func (s *fsRecordStore) Add(entry, txt string) error {
+	z, err := s.read()
+	if err != nil {
+		return err
+	}
+	fqdn := fmt.Sprintf("%s.%s.", entry, s.zone)
+	z.CreateOrReplaceTxtRecord(fqdn, txt)
+	// for _, ip := range s.publicIP {
+	// 	z.CreateARecord(fqdn, ip)
+	// }
+	return s.write(z)
+}
+
+func (s *fsRecordStore) Delete(entry, txt string) error {
+	z, err := s.read()
+	if err != nil {
+		return err
+	}
+	fqdn := fmt.Sprintf("%s.%s.", entry, s.zone)
+	z.DeleteTxtRecord(fqdn, txt)
+	// z.DeleteRecordsFor(fqdn)
+	return s.write(z)
+}