Files
vibedns/internal/web/handlers_policy.go
2026-08-16 21:18:45 -05:00

584 lines
16 KiB
Go

package web
import (
"fmt"
"net/http"
"strings"
"github.com/owen/vibedns/internal/app"
"github.com/owen/vibedns/internal/models"
)
// --- Client networks ----------------------------------------------------
func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) error {
search := formString(r, "q")
networks, err := s.app.Networks(r.Context(), search)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Client Networks", "networks")
data.Data = map[string]any{
"Networks": networks,
"Policies": policies,
"Search": search,
}
s.render(w, r, "networks", data)
return nil
}
func (s *Server) handleNetworkNew(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "New Client Network", "networks")
data.Data = map[string]any{
"Network": models.Network{Enabled: true},
"Policies": policies,
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/networks/new",
}
s.render(w, r, "network_form", data)
return nil
}
func networkInputFromForm(r *http.Request) app.NetworkInput {
enabled := formBool(r, "enabled")
return app.NetworkInput{
Name: formString(r, "name"),
CIDR: formString(r, "cidr"),
Description: formString(r, "description"),
Enabled: &enabled,
PolicyIDs: formInt64s(r, "policy_id"),
}
}
func (s *Server) handleNetworkCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.CreateNetwork(r.Context(), s.actor(r), networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s created.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
network, err := s.app.Network(r.Context(), id)
if err != nil {
return err
}
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
selected := map[int64]bool{}
for _, p := range network.Policies {
selected[p.ID] = true
}
data := s.base(r, network.Name, "networks")
data.Data = map[string]any{
"Network": network,
"Policies": policies,
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/networks/%d", id),
}
s.render(w, r, "network_form", data)
return nil
}
func (s *Server) handleNetworkUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, networkInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Network %s saved.", n.Name))
return s.redirect(w, r, "/policies/networks")
}
func (s *Server) handleNetworkToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetNetworkEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Network "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleNetworkDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Network deleted.")
return s.redirect(w, r, "/policies/networks")
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
// --- Policies -----------------------------------------------------------
func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) error {
policies, err := s.app.Policies(r.Context())
if err != nil {
return err
}
data := s.base(r, "Policy Rules", "policies")
data.Data = map[string]any{"Policies": policies}
s.render(w, r, "policies", data)
return nil
}
func (s *Server) handlePolicyNew(w http.ResponseWriter, r *http.Request) error {
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
data := s.base(r, "New Policy", "policies")
data.Data = map[string]any{
"Policy": models.Policy{
Enabled: true, BlockAction: models.BlockNXDOMAIN, BlockTTL: 60,
SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::",
},
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": map[int64]bool{},
"IsNew": true,
"FormAction": "/policies/rules/new",
}
s.render(w, r, "policy_form", data)
return nil
}
func filterLists(lists []models.DomainList, kind string) []models.DomainList {
var out []models.DomainList
for _, l := range lists {
if l.Kind == kind {
out = append(out, l)
}
}
return out
}
func policyInputFromForm(r *http.Request) app.PolicyInput {
enabled := formBool(r, "enabled")
return app.PolicyInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
Enabled: &enabled,
BlockAction: formString(r, "block_action"),
SinkholeIPv4: formString(r, "sinkhole_ipv4"),
SinkholeIPv6: formString(r, "sinkhole_ipv6"),
BlockTTL: formUint32(r, "block_ttl", 0),
ListIDs: formInt64s(r, "list_id"),
}
}
func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.CreatePolicy(r.Context(), s.actor(r), policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s created.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyEdit(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
policy, err := s.app.Policy(r.Context(), id)
if err != nil {
return err
}
lists, err := s.app.DomainLists(r.Context(), "", "")
if err != nil {
return err
}
selected := map[int64]bool{}
for _, lid := range policy.BlacklistIDs {
selected[lid] = true
}
for _, lid := range policy.AllowlistIDs {
selected[lid] = true
}
data := s.base(r, policy.Name, "policies")
data.Data = map[string]any{
"Policy": policy,
"Blacklists": filterLists(lists, models.KindBlacklist),
"Allowlists": filterLists(lists, models.KindAllowlist),
"Selected": selected,
"IsNew": false,
"FormAction": fmt.Sprintf("/policies/rules/%d", id),
}
s.render(w, r, "policy_form", data)
return nil
}
func (s *Server) handlePolicyUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, policyInputFromForm(r))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Policy %s saved.", p.Name))
return s.redirect(w, r, "/policies")
}
func (s *Server) handlePolicyToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetPolicyEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "Policy "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handlePolicyDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Policy deleted.")
return s.redirect(w, r, "/policies")
}
// --- Domain lists -------------------------------------------------------
func (s *Server) handleBlacklists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindBlacklist, "Blacklists", "blacklists")
}
func (s *Server) handleAllowlists(w http.ResponseWriter, r *http.Request) error {
return s.renderLists(w, r, models.KindAllowlist, "Allowlists", "allowlists")
}
func (s *Server) renderLists(w http.ResponseWriter, r *http.Request, kind, title, nav string) error {
search := formString(r, "q")
lists, err := s.app.DomainLists(r.Context(), kind, search)
if err != nil {
return err
}
data := s.base(r, title, nav)
data.Data = map[string]any{
"Lists": lists,
"Kind": kind,
"Search": search,
"IsBlacklist": kind == models.KindBlacklist,
}
s.render(w, r, "lists", data)
return nil
}
func (s *Server) handleListCreate(w http.ResponseWriter, r *http.Request) error {
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Kind: formString(r, "kind"),
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s created.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", l.ID))
}
func (s *Server) handleListDetail(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
p := newPagination(r, 100)
search := formString(r, "q")
entries, total, err := s.app.DomainEntries(r.Context(), id, search, p.PerPage, p.Offset)
if err != nil {
return err
}
nav := "blacklists"
if list.Kind == models.KindAllowlist {
nav = "allowlists"
}
data := s.base(r, list.Name, nav)
data.Data = map[string]any{
"List": list,
"Entries": entries,
"Pagination": p.withTotal(total),
"Search": search,
}
s.render(w, r, "list_detail", data)
return nil
}
func (s *Server) handleListUpdate(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
in := app.ListInput{
Name: formString(r, "name"),
Description: formString(r, "description"),
SourceURL: formString(r, "source_url"),
Enabled: &enabled,
}
l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s saved.", l.Name))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
func (s *Server) handleListToggle(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
enabled := formBool(r, "enabled")
if err := s.app.SetDomainListEnabled(r.Context(), s.actor(r), id, enabled); err != nil {
return err
}
setFlash(w, r, "success", "List "+enabledWord(enabled)+".")
s.redirectBack(w, r)
return nil
}
func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("%s deleted.", list.Name))
target := "/policies/blacklists"
if list.Kind == models.KindAllowlist {
target = "/policies/allowlists"
}
return s.redirect(w, r, target)
}
func (s *Server) handleListClear(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
n, err := s.app.ClearDomains(r.Context(), s.actor(r), id)
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Removed %s domains.", humanNumber(n)))
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// handleListImport accepts a pasted list or an uploaded file.
func (s *Server) handleListImport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
maxMB := s.app.Settings().HTTP.MaxUploadMB
if err := parseMultipart(r, 8); err != nil {
return err
}
matchSubdomains := formBool(r, "match_subdomains")
pasted := r.FormValue("content")
var reader = strings.NewReader(pasted)
if file, header, ferr := r.FormFile("file"); ferr == nil {
defer file.Close()
// The upload is streamed straight into the parser rather than being
// buffered as a string: blocklists routinely run to tens of megabytes.
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, file, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, header.Filename, summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
if strings.TrimSpace(pasted) == "" {
return app.Invalid("Paste a list of domains or choose a file to upload (up to %d MB).", maxMB)
}
summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, reader, matchSubdomains)
if err != nil {
return err
}
s.flashImportSummary(w, r, "", summary)
return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id))
}
// flashImportSummary reports exactly what the import did, which is the only
// way an operator can tell a 300,000 line file was handled correctly.
func (s *Server) flashImportSummary(w http.ResponseWriter, r *http.Request, filename string, sum models.ImportSummary) {
var b strings.Builder
if filename != "" {
fmt.Fprintf(&b, "Imported %s: ", filename)
} else {
b.WriteString("Import complete: ")
}
fmt.Fprintf(&b, "%s lines processed, %s domains added",
humanNumber(sum.LinesProcessed), humanNumber(sum.Imported))
if sum.Duplicates > 0 {
fmt.Fprintf(&b, ", %s duplicates skipped", humanNumber(sum.Duplicates))
}
if sum.Invalid > 0 {
fmt.Fprintf(&b, ", %s invalid entries", humanNumber(sum.Invalid))
}
if sum.Ignored > 0 {
fmt.Fprintf(&b, ", %s comments or blank lines ignored", humanNumber(sum.Ignored))
}
b.WriteString(".")
setFlash(w, r, "success", b.String())
}
func (s *Server) handleListExport(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
list, err := s.app.DomainList(r.Context(), id)
if err != nil {
return err
}
filename := strings.ReplaceAll(strings.ToLower(list.Name), " ", "-") + ".txt"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
// Streamed straight to the client: a large list must not be buffered.
if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil {
s.log.Error("list export failed mid-stream", "list", list.Name, "error", err)
}
return nil
}
func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
entry, err := s.app.AddDomain(r.Context(), s.actor(r), id,
formString(r, "domain"), formBool(r, "match_subdomains"), formString(r, "comment"))
if err != nil {
return err
}
setFlash(w, r, "success", fmt.Sprintf("Added %s.", entry.Domain))
s.redirectBack(w, r)
return nil
}
func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
if err := parseForm(r); err != nil {
return err
}
if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil {
return err
}
setFlash(w, r, "success", "Domain removed.")
s.redirectBack(w, r)
return nil
}