-
Build CoreDNS with the records plugin. This isn't super straight forward.
-
Build the magicdns-admin container
-
Start tailscale
-
Create SSL cert
-
Start nginx
-
Start app
-
Start coredns
| version: '3.8' | |
| volumes: | |
| dns_db: | |
| dns_ts: | |
| dns_tls: | |
| dns_coredns: | |
| services: | |
| coredns: | |
| image: coredns | |
| network_mode: service:tailscale | |
| restart: on-failure | |
| volumes: | |
| - dns_coredns:/etc/coredns/ | |
| entrypoint: /coredns | |
| command: -conf /etc/coredns/Corefile | |
| app: | |
| image: "magicdns-admin" | |
| restart: unless-stopped | |
| environment: | |
| - PDS_ADMIN_PASSWORD=secret | |
| - DATABASE=/etc/coredns/database.db | |
| - COREFILE=/etc/coredns/Corefile | |
| volumes: | |
| - dns_coredns:/etc/coredns/ | |
| tailscale: | |
| image: tailscale/tailscale:latest | |
| restart: unless-stopped | |
| environment: | |
| - TS_AUTHKEY=tskey-auth-your-secret-goes-here | |
| - TS_STATE_DIR=/var/run/tailscale | |
| - TS_EXTRA_ARGS=--advertise-tags=tag:pdsdns | |
| - TS_HOSTNAME=pdsdns | |
| volumes: | |
| - dns_tls:/mnt/tls | |
| - dns_ts:/var/run/tailscale | |
| nginx: | |
| image: nginx | |
| restart: unless-stopped | |
| network_mode: service:tailscale | |
| volumes: | |
| - ./nginx.conf:/etc/nginx/nginx.conf | |
| - dns_tls:/mnt/tls:ro |
| FROM golang:alpine3.21 AS build | |
| ENV CGO_ENABLED=1 | |
| RUN apk add --no-cache gcc musl-dev | |
| WORKDIR /workspace | |
| COPY go.mod /workspace/ | |
| COPY go.sum /workspace/ | |
| RUN go mod download | |
| COPY main.go /workspace/ | |
| ENV GOCACHE=/root/.cache/go-build | |
| RUN --mount=type=cache,target="/root/.cache/go-build" go install -ldflags='-s -w -extldflags "-static"' ./main.go | |
| FROM scratch | |
| COPY --from=build /go/bin/main /usr/local/bin/magicdev-admin | |
| COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ | |
| ENTRYPOINT [ "/usr/local/bin/magicdev-admin" ] |
| module github.com/astrenoxcoop/magicdev-admin | |
| go 1.23.5 | |
| require ( | |
| github.com/coreos/go-semver v0.3.0 // indirect | |
| github.com/coreos/go-systemd/v22 v22.3.2 // indirect | |
| github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 // indirect | |
| github.com/gogo/protobuf v1.3.2 // indirect | |
| github.com/golang/protobuf v1.5.4 // indirect | |
| github.com/mattn/go-sqlite3 v1.14.24 // indirect | |
| github.com/sethvargo/go-envconfig v1.1.0 // indirect | |
| go.etcd.io/etcd/api/v3 v3.5.17 // indirect | |
| go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect | |
| go.etcd.io/etcd/client/v3 v3.5.17 // indirect | |
| go.uber.org/atomic v1.7.0 // indirect | |
| go.uber.org/multierr v1.6.0 // indirect | |
| go.uber.org/zap v1.17.0 // indirect | |
| golang.org/x/net v0.23.0 // indirect | |
| golang.org/x/sys v0.18.0 // indirect | |
| golang.org/x/text v0.14.0 // indirect | |
| google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect | |
| google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect | |
| google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect | |
| google.golang.org/grpc v1.59.0 // indirect | |
| google.golang.org/protobuf v1.33.0 // indirect | |
| ) |
| package main | |
| import ( | |
| "bytes" | |
| "context" | |
| "database/sql" | |
| "encoding/base64" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "os" | |
| "text/template" | |
| petname "github.com/dustinkirkland/golang-petname" | |
| _ "github.com/mattn/go-sqlite3" | |
| "github.com/sethvargo/go-envconfig" | |
| ) | |
| type ServerConfig struct { | |
| Database string `env:"DATABASE, default=./database.db"` | |
| PDSHostName string `env:"PDS_HOSTNAME, default=pds.my.ts.net"` | |
| PDSAdminPassword string `env:"PDS_ADMIN_PASSWORD, required"` | |
| Domain string `env:"DOMAIN, default=pyroclastic.cloud"` | |
| Corefile string `env:"COREFILE, default=./Corefile"` | |
| } | |
| type createInviteResponse struct { | |
| Code string `json:"code"` | |
| } | |
| type createAccountRequest struct { | |
| Email string `json:"email"` | |
| Handle string `json:"handle"` | |
| Password string `json:"password"` | |
| InviteCode string `json:"inviteCode"` | |
| } | |
| type createAccountResponse struct { | |
| DID string `json:"did"` | |
| } | |
| func createInvite(server, password string) (string, error) { | |
| url := fmt.Sprintf("https://%s/xrpc/com.atproto.server.createInviteCode", server) | |
| requestBody := []byte(`{"useCount":1}`) | |
| req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) | |
| if err != nil { | |
| return "", err | |
| } | |
| req.Header.Set("Content-Type", "application/json") | |
| req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:"+password))) | |
| client := &http.Client{} | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| return "", err | |
| } | |
| decoder := json.NewDecoder(resp.Body) | |
| var createInvite createInviteResponse | |
| err = decoder.Decode(&createInvite) | |
| if err != nil { | |
| return "", err | |
| } | |
| return createInvite.Code, nil | |
| } | |
| func createAccount(server, password, inviteCode, handle, email string) (string, error) { | |
| url := fmt.Sprintf("https://%s/xrpc/com.atproto.server.createAccount", server) | |
| createAccountRequestBody := createAccountRequest{ | |
| Email: email, | |
| Handle: handle, | |
| Password: "password", | |
| InviteCode: inviteCode, | |
| } | |
| requestBody, err := json.Marshal(createAccountRequestBody) | |
| if err != nil { | |
| return "", err | |
| } | |
| req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) | |
| if err != nil { | |
| return "", err | |
| } | |
| req.Header.Set("Content-Type", "application/json") | |
| req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("admin:"+password))) | |
| client := &http.Client{} | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| return "", err | |
| } | |
| decoder := json.NewDecoder(resp.Body) | |
| var createdAccount createAccountResponse | |
| err = decoder.Decode(&createdAccount) | |
| if err != nil { | |
| return "", err | |
| } | |
| return createdAccount.DID, nil | |
| } | |
| type handlers struct { | |
| config *ServerConfig | |
| db *sql.DB | |
| } | |
| func (h *handlers) indexHandler(w http.ResponseWriter, r *http.Request) { | |
| handle := r.PostFormValue("handle") | |
| if handle == "" { | |
| handle = petname.Generate(2, "-") | |
| body := fmt.Sprintf(`<html><body><form method="post" action="/"><input type="text" name="handle" value="%s" /><input type="submit" /></form></body></html>`, handle) | |
| io.WriteString(w, body) | |
| return | |
| } | |
| inviteCode, err := createInvite(h.config.PDSHostName, h.config.PDSAdminPassword) | |
| if err != nil { | |
| io.WriteString(w, fmt.Sprintf("Error: %s", err)) | |
| return | |
| } | |
| email := fmt.Sprintf("%s@%s", handle, h.config.Domain) | |
| full_handle := fmt.Sprintf("%s.%s", handle, h.config.Domain) | |
| did, err := createAccount(h.config.PDSHostName, h.config.PDSAdminPassword, inviteCode, full_handle, email) | |
| if err != nil { | |
| io.WriteString(w, fmt.Sprintf("Error: %s", err)) | |
| return | |
| } | |
| _, err = h.db.Exec(`INSERT INTO handles (did, handle) VALUES (?, ?)`, &did, &handle) | |
| if err != nil { | |
| io.WriteString(w, fmt.Sprintf("Error: %s", err)) | |
| return | |
| } | |
| if err = generateCorefile(h.config, h.db); err != nil { | |
| io.WriteString(w, fmt.Sprintf("Error: %s", err)) | |
| return | |
| } | |
| body := fmt.Sprintf(`<html><body><p>Created <span>%s</span> with handle <span>%s</span></p><p><a href="/">Back</a></body></html>`, did, full_handle) | |
| io.WriteString(w, body) | |
| } | |
| func (h *handlers) newHandler(w http.ResponseWriter, r *http.Request) { | |
| handle := r.PostFormValue("handle") | |
| if handle == "" { | |
| handle = "testy" | |
| } | |
| io.WriteString(w, fmt.Sprintf("Hello, %s!\n", handle)) | |
| } | |
| func generateCorefile(config *ServerConfig, db *sql.DB) error { | |
| corefileTemplate := ` | |
| . { | |
| log | |
| errors | |
| reload 10s | |
| records {{ .Domain }} { | |
| @ 60 IN TXT "TEST" | |
| {{ range .Records }} | |
| _atproto.{{ .Handle }} 60 IN TXT "did={{ .DID }}"{{ end }} | |
| } | |
| }` | |
| corefile, err := template.New("corefile").Parse(corefileTemplate) | |
| if err != nil { | |
| log.Fatal(err) | |
| } | |
| type corefileValueRecord struct { | |
| DID string | |
| Handle string | |
| } | |
| type corefileValues struct { | |
| Domain string | |
| Records []corefileValueRecord | |
| } | |
| records := make([]corefileValueRecord, 0) | |
| rows, err := db.Query("SELECT handle, did FROM handles") | |
| if err != nil { | |
| return err | |
| } | |
| defer rows.Close() | |
| for rows.Next() { | |
| var handle string | |
| var did string | |
| err = rows.Scan(&handle, &did) | |
| if err != nil { | |
| return err | |
| } | |
| records = append(records, corefileValueRecord{did, handle}) | |
| } | |
| err = rows.Err() | |
| if err != nil { | |
| return err | |
| } | |
| data := corefileValues{ | |
| Domain: config.Domain, | |
| Records: records, | |
| } | |
| output, err := os.Create(config.Corefile) | |
| if err != nil { | |
| return err | |
| } | |
| defer output.Close() | |
| err = corefile.Execute(output, data) | |
| if err != nil { | |
| log.Fatal(err) | |
| } | |
| return nil | |
| } | |
| func main() { | |
| ctx := context.Background() | |
| var config ServerConfig | |
| if err := envconfig.Process(ctx, &config); err != nil { | |
| log.Fatal(err) | |
| } | |
| db, err := sql.Open("sqlite3", config.Database) | |
| if err != nil { | |
| log.Fatal(err) | |
| } | |
| defer db.Close() | |
| _, err = db.Exec(`CREATE TABLE IF NOT EXISTS handles (did TEXT NOT NULL PRIMARY KEY, handle TEXT NOT NULL UNIQUE)`) | |
| if err != nil { | |
| log.Fatal(err) | |
| } | |
| h := handlers{ | |
| config: &config, | |
| db: db, | |
| } | |
| mux := http.NewServeMux() | |
| mux.HandleFunc("/", h.indexHandler) | |
| mux.HandleFunc("/new", h.newHandler) | |
| if err = http.ListenAndServe(":3333", mux); !errors.Is(err, http.ErrServerClosed) { | |
| log.Fatal(err) | |
| } | |
| } |
| events {} | |
| http { | |
| server { | |
| resolver 127.0.0.11 [::1]:5353 valid=15s; | |
| set $backend "http://app:3333"; | |
| listen 443 ssl; | |
| ssl_certificate /mnt/tls/cert.pem; | |
| ssl_certificate_key /mnt/tls/cert.key; | |
| location / { | |
| proxy_pass $backend; | |
| proxy_set_header Host $host; | |
| proxy_set_header X-Real-IP $remote_addr; | |
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| client_max_body_size 64M; | |
| } | |
| } | |
| } |