initial commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user