-
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; | |
} | |
} | |
} |