// Package console is the editing and viewing half of fancy-dns, split from the // resolver so that it can run somewhere else. It owns authentication and the // web UI, writes ClientGroup, BlockList and DNSZone objects to the cluster, and // forwards everything the resolver owns (statistics, the query log, the cache) // to the resolver's admin API. // // The resolver never calls the console and the console is never on the DNS // path: stopping it cannot affect a query. package console import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "time" "git.k8s-git-k6n.net/mats/fancy-dns/internal/api" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit" "git.k8s-git-k6n.net/mats/fancy-dns/internal/web" ) // Options configure a Console. type Options struct { Config *config.Store Runtime *Runtime Editor *kubeedit.Editor Logger *slog.Logger Version string } // Console serves the UI and the edit API. type Console struct { opt Options log *slog.Logger api *api.Server started time.Time } // New builds a Console. func New(opt Options) (*Console, error) { if opt.Config == nil || opt.Runtime == nil || opt.Editor == nil { return nil, errors.New("console: config, runtime and editor are required") } if opt.Logger == nil { opt.Logger = slog.Default() } c := &Console{opt: opt, log: opt.Logger.With("component", "console"), started: time.Now()} srv, err := api.New(c.hooks()) if err != nil { return nil, err } c.api = srv return c, nil } // actor names who is making a request, for the object annotation. func actor(ctx context.Context) string { if p, ok := api.PrincipalFrom(ctx); ok && p.Name != "" { return p.Name } return "" } // Handler is the whole HTTP surface: the console's own routes, the resolver's // API behind the same authentication, and the single-page app. func (c *Console) Handler() (http.Handler, error) { cfg := c.opt.Config.Get() base := strings.TrimSuffix(cfg.API.BasePath, "/") own := c.api.Handler() mux := http.NewServeMux() // Handled by the shared API server: authentication, the identity of the // caller, and the edits whose hooks write to the cluster. for _, p := range []string{ base + "/auth/", "GET " + base + "/whoami", "GET " + base + "/links", "GET " + base + "/healthz", "PUT " + base + "/clients", "POST " + base + "/zones/records", "PUT " + base + "/zones/records", "DELETE " + base + "/zones/records", "POST " + base + "/zones/delegations", "DELETE " + base + "/zones/delegations", "DELETE " + base + "/zones", "POST " + base + "/blocklists/toggle", "GET " + base + "/address-overrides", "PUT " + base + "/address-overrides", "DELETE " + base + "/address-overrides/{domain}", "POST " + base + "/domains", "DELETE " + base + "/domains/{name...}", } { mux.Handle(p, own) } // Reads that combine the cluster's objects with the resolver's view. mux.Handle("GET "+base+"/zones", c.api.Protect(http.HandlerFunc(c.handleZones), false)) mux.Handle("GET "+base+"/clients", c.api.Protect(http.HandlerFunc(c.handleClients), false)) mux.Handle("POST "+base+"/clients/validate", c.api.Protect(http.HandlerFunc(c.handleClientsValidate), false)) mux.Handle("GET "+base+"/blocklist-sources", c.api.Protect(http.HandlerFunc(c.handleListSources), false)) mux.Handle("PUT "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourcePut), true)) mux.Handle("DELETE "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourceDelete), true)) // Everything else under the API belongs to the resolver. proxy := c.opt.Runtime.Proxy() read, write := c.api.Protect(proxy, false), c.api.Protect(proxy, true) mux.Handle(base+"/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead { read.ServeHTTP(w, r) return } write.ServeHTTP(w, r) })) if cfg.API.UI { ui, err := web.Handler("") if err != nil { return nil, fmt.Errorf("web console: %w", err) } mux.Handle("/", ui) } return mux, nil }