Files
2026-08-16 21:18:45 -05:00

238 lines
7.4 KiB
Go

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
}