package dns import ( "log" "net/http" "strconv" "strings" "penguins.lan/api/types" "penguins.lan/database" "penguins.lan/utils" ) const MaxUserRecords = 50 var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "TXT": true} // ListDNSRecordsContinuation loads the current user's records for the editor. func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { if context.User == nil { return failure(context, req, resp) } records, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) if err != nil { log.Println("dns: list records failed:", err) resp.WriteHeader(http.StatusInternalServerError) return failure(context, req, resp) } (*context.TemplateData)["DNSRecords"] = records return success(context, req, resp) } } // CreateDNSRecordContinuation validates and upserts a record. Names are confined // to the user's own . subtree; bare/relative names are expanded // to it for convenience. func CreateDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { fail := func(msg string, code int) types.ContinuationChain { resp.WriteHeader(code) (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{msg}} return failure(context, req, resp) } recordType := strings.ToUpper(strings.TrimSpace(req.FormValue("type"))) if !allowedTypes[recordType] { return fail("type must be one of A, AAAA, CNAME, TXT", http.StatusBadRequest) } name, ok := qualifyName(req.FormValue("name"), context.User.Username, context.Args.DnsZone) if !ok { return fail("name must be under "+context.User.Username+"."+context.Args.DnsZone, http.StatusBadRequest) } content := strings.TrimSpace(req.FormValue("content")) if content == "" { return fail("content can't be empty", http.StatusBadRequest) } ttl := 300 if raw := strings.TrimSpace(req.FormValue("ttl")); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n < 0 { return fail("ttl must be a non-negative number", http.StatusBadRequest) } ttl = n } count, err := database.CountUserDNSRecords(context.DBConn, context.User.ID) if err != nil { return fail("something broke, try again", http.StatusInternalServerError) } if count >= MaxUserRecords { return fail("you've hit the record limit", http.StatusTooManyRequests) } // A name may hold at most one CNAME; replace any existing one so // repointing the primary (or re-running the wizard) doesn't duplicate. if recordType == "CNAME" { if err := database.DeleteUserRecordsByNameType(context.DBConn, context.User.ID, name, "CNAME"); err != nil { return fail("something broke, try again", http.StatusInternalServerError) } } record := &database.DNSRecord{ ID: utils.RandomId(), UserID: context.User.ID, Name: name, Type: recordType, Content: content, TTL: ttl, } if _, err := database.SaveDNSRecord(context.DBConn, record); err != nil { log.Println("dns: save record failed:", err) return fail("couldn't save that record", http.StatusInternalServerError) } http.Redirect(resp, req, "/profile", http.StatusSeeOther) return success(context, req, resp) } } // DeleteDNSRecordContinuation removes a record the caller owns. func DeleteDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { record, err := database.GetDNSRecord(context.DBConn, req.FormValue("id")) if err != nil { resp.WriteHeader(http.StatusNotFound) return failure(context, req, resp) } if record.UserID != context.User.ID { resp.WriteHeader(http.StatusUnauthorized) return failure(context, req, resp) } if err := database.DeleteDNSRecord(context.DBConn, record.ID); err != nil { log.Println("dns: delete record failed:", err) resp.WriteHeader(http.StatusInternalServerError) return failure(context, req, resp) } http.Redirect(resp, req, "/profile", http.StatusSeeOther) return success(context, req, resp) } } // qualifyName normalizes a record name to a fully-qualified name (trailing dot) // confined to the user's . subtree. A name already under the // subtree is kept; a name elsewhere in the zone is rejected; anything else is // treated as relative to the subtree (so "www" -> "www..."). func qualifyName(raw, username, zone string) (string, bool) { raw = strings.ToLower(strings.TrimSpace(raw)) raw = strings.TrimSuffix(raw, ".") if raw == "" { return "", false } zone = strings.ToLower(zone) userDomain := username + "." + zone switch { case raw == userDomain || strings.HasSuffix(raw, "."+userDomain): return raw + ".", true case raw == zone || strings.HasSuffix(raw, "."+zone): return "", false // in the zone but not the user's subtree default: return raw + "." + userDomain + ".", true } }