package app import ( "context" "errors" "fmt" "strings" "time" "github.com/miekg/dns" "github.com/owen/vibedns/internal/auditlog" "github.com/owen/vibedns/internal/auth" "github.com/owen/vibedns/internal/config" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/resolver" ) // Admin returns the administrator account. func (a *App) Admin(ctx context.Context) (models.Admin, error) { admin, err := a.DB.Admin(ctx) if errors.Is(err, database.ErrNotFound) { return admin, NotFound("No administrator account exists.") } if err != nil { return admin, Internal(err, "The administrator account could not be loaded.") } return admin, nil } // ChangeCredentials updates the administrator username and/or password. // // The current password is always required: knowing the session is // authenticated is not enough, because HTTP Basic credentials are replayed by // the browser and a stolen session should not be able to lock out the owner. func (a *App) ChangeCredentials(ctx context.Context, actor auditlog.Actor, currentPassword, newUsername, newPassword, confirmPassword string) error { admin, err := a.Admin(ctx) if err != nil { return err } ok, verr := auth.VerifyPassword(admin.PasswordHash, currentPassword) if verr != nil { return Internal(verr, "The stored password could not be verified.") } if !ok { return Forbidden("The current password is incorrect.") } username := strings.TrimSpace(newUsername) if username == "" { username = admin.Username } if err := config.ValidateUsername(username); err != nil { return Invalid("%s", err.Error()) } hash := admin.PasswordHash passwordChanged := false if newPassword != "" { if newPassword != confirmPassword { return Invalid("The new passwords do not match.") } if err := auth.ValidatePassword(newPassword); err != nil { return Invalid("%s", err.Error()) } if newPassword == currentPassword { return Invalid("The new password must differ from the current one.") } hash, err = auth.HashPassword(newPassword) if err != nil { return Internal(err, "The new password could not be stored.") } passwordChanged = true } else if auth.NeedsRehash(admin.PasswordHash) { // Take the opportunity to upgrade an old hash while we have the // plaintext in hand. if h, herr := auth.HashPassword(currentPassword); herr == nil { hash = h } } if username == admin.Username && !passwordChanged { return Invalid("Nothing was changed.") } if err := a.DB.UpdateAdminCredentials(ctx, username, hash, false); err != nil { return Internal(err, "The credentials could not be saved.") } // The old password must stop working immediately. a.Auth.InvalidateCredentials() what := []string{} if username != admin.Username { what = append(what, "username") } if passwordChanged { what = append(what, "password") } a.Audit.Record(ctx, actor, "admin.credentials_changed", auditlog.ObjectAdmin, "1", username, auditlog.Changes("changed", strings.Join(what, " and "))) return nil } // --- API tokens --------------------------------------------------------- // APITokens lists every token. Secrets are never included. func (a *App) APITokens(ctx context.Context) ([]models.APIToken, error) { tokens, err := a.DB.APITokens(ctx) if err != nil { return nil, Internal(err, "The API tokens could not be loaded.") } return tokens, nil } // CreateAPIToken mints a token. The secret is returned once and never stored. func (a *App) CreateAPIToken(ctx context.Context, actor auditlog.Actor, name, description string) (models.APIToken, error) { name = strings.TrimSpace(name) if name == "" { return models.APIToken{}, Invalid("A token name is required.") } if len(name) > 100 { return models.APIToken{}, Invalid("The token name must be 100 characters or fewer.") } tok, err := auth.GenerateToken() if err != nil { return models.APIToken{}, Internal(err, "The token could not be generated.") } created, err := a.DB.CreateAPIToken(ctx, name, strings.TrimSpace(description), tok.Prefix, tok.Hash) if err != nil { return models.APIToken{}, translate(err, "Token not found.", fmt.Sprintf("An API token named %q already exists.", name)) } created.Secret = tok.Secret // The audit entry records that a token was created, never its value. a.Audit.RecordID(ctx, actor, "token.create", auditlog.ObjectToken, created.ID, name, auditlog.Changes("prefix", tok.Prefix)) return created, nil } // SetAPITokenEnabled enables or disables a token. func (a *App) SetAPITokenEnabled(ctx context.Context, actor auditlog.Actor, id int64, enabled bool) error { tok, err := a.DB.APIToken(ctx, id) if err != nil { return translate(err, fmt.Sprintf("API token %d was not found.", id), "") } if err := a.DB.SetAPITokenEnabled(ctx, id, enabled); err != nil { return translate(err, fmt.Sprintf("API token %d was not found.", id), "") } action := "token.disable" if enabled { action = "token.enable" } a.Audit.RecordID(ctx, actor, action, auditlog.ObjectToken, id, tok.Name, "") return nil } // DeleteAPIToken revokes a token permanently. func (a *App) DeleteAPIToken(ctx context.Context, actor auditlog.Actor, id int64) error { tok, err := a.DB.APIToken(ctx, id) if err != nil { return translate(err, fmt.Sprintf("API token %d was not found.", id), "") } if err := a.DB.DeleteAPIToken(ctx, id); err != nil { return translate(err, fmt.Sprintf("API token %d was not found.", id), "") } a.Audit.RecordID(ctx, actor, "token.revoke", auditlog.ObjectToken, id, tok.Name, "") return nil } // --- Diagnostics -------------------------------------------------------- // resolverCheck is a thin seam so settings.go can probe an upstream without // importing the resolver package itself. func resolverCheck(ctx context.Context, addr, qname string, s config.Settings) (time.Duration, string, error) { timeout := time.Duration(s.Resolver.TimeoutMS) * time.Millisecond return resolver.Check(ctx, addr, qname, timeout) } // LookupResult is the outcome of the UI's built-in query tool. type LookupResult struct { Question string `json:"question"` Rcode string `json:"rcode"` Source string `json:"source"` Answers []string `json:"answers"` Authority []string `json:"authority"` Duration time.Duration `json:"duration"` } // Lookup runs a query through the full server pipeline, exactly as a client on // the given address would experience it. func (a *App) Lookup(ctx context.Context, name, qtype, clientIP string, dnssec bool) (*LookupResult, error) { name = strings.TrimSpace(name) if name == "" { return nil, Invalid("Enter a name to look up.") } fqdn := dns.Fqdn(name) t, ok := dns.StringToType[strings.ToUpper(strings.TrimSpace(qtype))] if !ok { if qtype == "" { t = dns.TypeA } else { return nil, Invalid("%q is not a known record type.", qtype) } } client, ok := netipAddr(clientIP) if !ok { return nil, Invalid("%q is not a valid client IP address.", clientIP) } start := time.Now() msg, source, err := a.DNS.Resolve(ctx, fqdn, t, client, dnssec) if err != nil { return nil, Internal(err, "The lookup could not be completed.") } res := &LookupResult{ Question: fmt.Sprintf("%s %s", fqdn, dns.TypeToString[t]), Rcode: dns.RcodeToString[msg.Rcode], Source: source, Duration: time.Since(start), } for _, rr := range msg.Answer { res.Answers = append(res.Answers, rr.String()) } for _, rr := range msg.Ns { res.Authority = append(res.Authority, rr.String()) } return res, nil }