diff --git a/go.mod b/go.mod index 81f59fa..0201417 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.32 github.com/miekg/dns v1.1.69 + github.com/pressly/goose/v3 v3.26.0 ) require ( github.com/mfridman/interpolate v0.0.2 // indirect - github.com/pressly/goose/v3 v3.26.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/mod v0.30.0 // indirect diff --git a/go.sum b/go.sum index 8225808..d33e6ee 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,37 @@ +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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= @@ -24,3 +42,13 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/index.html b/index.html new file mode 100644 index 0000000..5848712 --- /dev/null +++ b/index.html @@ -0,0 +1,112 @@ + + + + + + DNS Admin + + + +

DNS Admin

+ +

Manage Records

+
+ + + + +
+ + {{if .Records}} + + + + + + + + + + + {{range .Records}} + + + + + + + {{end}} + +
DomainIPType
{{.Domain}}{{.IP}}{{.RecordType}} +
+ + + + +
+
+ {{end}} + +

Manage Upstreams

+
+ + +
+ + {{if .Upstreams}} + + + + + + + + + {{range .Upstreams}} + + + + + {{end}} + +
Address
{{.Address}} +
+ + + +
+
+ {{end}} + + diff --git a/main.go b/main.go index ea0003a..8a83191 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,8 @@ package main import ( "database/sql" "embed" - "encoding/json" "fmt" + "html/template" "log" "log/slog" "net/http" @@ -19,19 +19,28 @@ import ( "github.com/pressly/goose/v3" ) -//go:embed migrations/*.sql +//go:embed migrations/*.sql index.html var embedMigrations embed.FS -type RecordRequest struct { - Domain string `json:"domain"` - IP string `json:"ip"` - Type string `json:"type"` +var indexTmpl = template.Must(template.ParseFS(embedMigrations, "index.html")) + +type Record struct { + Domain string + IP string + RecordType string } -type UpstreamRequest struct { - Address string `json:"address"` +type Upstream struct { + Address string } +type PageData struct { + Records []Record + Upstreams []Upstream +} + + + func initDB(dbPath string) *sql.DB { db, err := sql.Open("sqlite3", dbPath) if err != nil { @@ -136,82 +145,136 @@ func (resolver *DNSResolver) resolveUpstream(r *dns.Msg) (*dns.Msg, error) { } func startAPIServer(db *sql.DB, apiPort string) { - http.HandleFunc("/records", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - var req RecordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + recordsRows, err := db.Query("SELECT domain, ip, record_type FROM records") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer recordsRows.Close() + + var records []Record + for recordsRows.Next() { + var rec Record + if err := recordsRows.Scan(&rec.Domain, &rec.IP, &rec.RecordType); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } + records = append(records, rec) + } + upstreamsRows, err := db.Query("SELECT address FROM upstreams") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer upstreamsRows.Close() + + var upstreams []Upstream + for upstreamsRows.Next() { + var ups Upstream + if err := upstreamsRows.Scan(&ups.Address); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + upstreams = append(upstreams, ups) + } + + data := PageData{ + Records: records, + Upstreams: upstreams, + } + + if err := indexTmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + http.HandleFunc("/records", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + domain := r.FormValue("domain") + ip := r.FormValue("ip") + recordType := r.FormValue("type") + method := r.FormValue("_method") + + if method == "" { + method = r.Method + } + + if method == http.MethodPost { // Normalize domain to ensure trailing dot - if !strings.HasSuffix(req.Domain, ".") { - req.Domain += "." + if !strings.HasSuffix(domain, ".") { + domain += "." } - if req.Type == "" { - req.Type = "A" + if recordType == "" { + recordType = "A" } - req.Type = strings.ToUpper(req.Type) + recordType = strings.ToUpper(recordType) - if req.Type != "A" && req.Type != "AAAA" { + if recordType != "A" && recordType != "AAAA" { http.Error(w, "Invalid record type. Must be A or AAAA", http.StatusBadRequest) return } - - _, err := db.Exec("INSERT OR REPLACE INTO records (domain, ip, record_type) VALUES (?, ?, ?)", req.Domain, req.IP, req.Type) + _, err := db.Exec("INSERT OR REPLACE INTO records (domain, ip, record_type) VALUES (?, ?, ?)", domain, ip, recordType) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, "Added %s (%s) -> %s", req.Domain, req.Type, req.IP) - - } else if r.Method == http.MethodDelete { - var req RecordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if !strings.HasSuffix(req.Domain, ".") { - req.Domain += "." + w.Header().Set("Location", "/") + w.WriteHeader(http.StatusSeeOther) + } else if method == http.MethodDelete { + if !strings.HasSuffix(domain, ".") { + domain += "." } - _, err := db.Exec("DELETE FROM records WHERE domain = ? AND type = ?", req.Domain, req.Type) + _, err := db.Exec("DELETE FROM records WHERE domain = ? AND record_type = ?", domain, recordType) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - fmt.Fprintf(w, "Deleted %s", req.Domain) + w.Header().Set("Location", "/") + w.WriteHeader(http.StatusSeeOther) } }) http.HandleFunc("/upstreams", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - var req UpstreamRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - _, err := db.Exec("INSERT INTO upstreams (address) VALUES (?)", req.Address) + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + address := r.FormValue("address") + method := r.FormValue("_method") + + if method == "" { + method = r.Method + } + + if method == http.MethodPost { + _, err := db.Exec("INSERT INTO upstreams (address) VALUES (?)", address) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - w.WriteHeader(http.StatusCreated) - fmt.Fprintf(w, "Added upstream %s", req.Address) - } else if r.Method == http.MethodDelete { - var req UpstreamRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - _, err := db.Exec("DELETE FROM upstreams WHERE address = ?", req.Address) + w.Header().Set("Location", "/") + w.WriteHeader(http.StatusSeeOther) + } else if method == http.MethodDelete { + _, err := db.Exec("DELETE FROM upstreams WHERE address = ?", address) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - fmt.Fprintf(w, "Deleted upstream %s", req.Address) + w.Header().Set("Location", "/") + w.WriteHeader(http.StatusSeeOther) } })