From cfc0d34a584254a9ed76620951c810771aff6f3b Mon Sep 17 00:00:00 2001 From: Alexander Hunt Date: Sun, 21 Jun 2026 12:43:31 -0700 Subject: STUFF --- .env.example | 9 ++ README.md | 94 -------------------- api/auth/auth.go | 15 ++++ api/canvas/canvas.go | 33 ++++--- api/canvas/canvas_test.go | 2 - api/devices/devices.go | 92 ++++++++++++++++++++ api/dns/dns.go | 147 +++++++++++++++++++++++++++++++ api/serve.go | 30 +++++-- args/args.go | 29 +++++++ database/connections.go | 16 ++++ database/devices.go | 64 ++++++++++++++ database/dns.go | 157 +++++++++++++++++++++++++++++++++ database/dns_test.go | 62 +++++++++++++ database/migrate.go | 44 ++++++++++ database/users.go | 41 +++++++++ db/.gitkeep | 0 dnsd/server.go | 201 ++++++++++++++++++++++++++++++++++++++++++ dnsd/server_test.go | 172 ++++++++++++++++++++++++++++++++++++ go.mod | 6 ++ go.sum | 14 +++ main.go | 22 ++++- mise.toml | 2 + static/css/styles.css | 55 +++++++++++- static/js/canvas.js | 216 ++++++++++++++++++++++++++++++++++++++-------- static/js/dns-wizard.js | 43 +++++++++ templates/base.html | 2 - templates/canvas.html | 22 ----- templates/home.html | 28 +++++- templates/portal.html | 18 +--- templates/profile.html | 74 +++++++++++++++- utils/net.go | 26 ++++++ 31 files changed, 1528 insertions(+), 208 deletions(-) delete mode 100644 README.md create mode 100644 api/devices/devices.go create mode 100644 api/dns/dns.go create mode 100644 database/devices.go create mode 100644 database/dns.go create mode 100644 database/dns_test.go create mode 100644 db/.gitkeep create mode 100644 dnsd/server.go create mode 100644 dnsd/server_test.go create mode 100644 mise.toml create mode 100644 static/js/dns-wizard.js delete mode 100644 templates/canvas.html diff --git a/.env.example b/.env.example index 9764eed..9d36a7c 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,12 @@ # LAN_SUBNET is swept (TCP probes) so idle devices populate the ARP table and # show as "online". Leave empty to disable. Max /20. # LAN_SUBNET=192.168.8.0/24 + +# DNS server (enable with the -dns flag). It's authoritative for the zone +# (-dns-zone, default penguins.lan): every device answers at +# .. and users manage their own records from /profile. +# SERVER_IP answers the zone apex so the website itself resolves; leave empty to +# skip the apex. Queries outside the zone go to -dns-resolvers (comma-separated, +# empty = no forwarding). Real port 53 needs root or CAP_NET_BIND_SERVICE; the +# default -dns-port is 8053. +# SERVER_IP=192.168.8.1 diff --git a/README.md b/README.md deleted file mode 100644 index 70de28d..0000000 --- a/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# 🐧 penguins.lan - -A little intranet for a battery-powered LAN with no internet. The successor to -`inetravel` — same "have fun on the network" spirit, but a real Go app instead -of PHP includes, and actually fun stuff: live chat now, an r/place-style shared -canvas next. - -## Stack - -- **Go** standard-library HTTP server, no framework. -- **SQLite** (`mattn/go-sqlite3`) for persistence. -- **gorilla/websocket** for the realtime hub. -- `html/template` for server-rendered pages. - -## Architecture - -Requests flow through a small continuation-passing pipeline. Each route in -[`api/serve.go`](api/serve.go) reads as a flat chain of `Continuation`s, where -every link gets a `(success, failure)` pair and decides which to call. Shared -state for a request lives on `types.RequestContext`. - -``` -LogRequest -> ResolveSession -> RequireUser -> -> Template -> LogTime -``` - -Layout: - -| path | what | -|------------------|-------------------------------------------------------------| -| `main.go` | entrypoint: load env, open db, migrate, serve | -| `args/` | flag parsing | -| `database/` | sqlite conn, migrations, per-feature queries | -| `adapters/arp/` | resolves a client IP -> MAC from the host's ARP table | -| `api/` | server wiring (`serve.go`) + feature packages | -| `api/auth/` | **MAC-based identity** — sessions, portal, reclaim | -| `api/ws/` | **websocket hub** — broadcast chat, foundation for realtime | -| `api/template/` | html/template rendering + 404 handling | -| `templates/` | server-rendered pages, wrapped in `base.html` | -| `static/` | css / js / images | - -## Identity (`api/auth` + `adapters/arp`) - -No passwords — on a trust-based LAN your **MAC address is your credential**. The -router (nodogsplash) keeps every client on one L2 segment, so the server reads -each visitor's MAC from the ARP table (`arp -an`). - -- **First visit** → the portal asks for a handle; claiming it creates a `user`, - binds your current MAC, and sets a session cookie. -- **Return visit** → a live session cookie logs you in; if it's gone, a - recognised MAC logs you straight back in. -- **Rotated MAC** (privacy randomization) → you show up as a new device. The - portal reports when that handle was *last online* and lets you **log back in** - to reclaim it, re-binding your new MAC to the existing account. - -One user can own many MACs (`macs` table). Chat messages reference `user_id` -(not a name), so identity survives MAC rotation and renames. - -> **Local dev:** the browser is on loopback and never appears in the ARP table, -> so set `DEV_MAC=aa:bb:cc:dd:ee:ff` to force a MAC and exercise the whole flow. -> Never set it in production — it would make every visitor one shared identity. - -## Realtime (`api/ws`) - -The `Hub` owns the set of connected clients and a broadcast channel; all client -set mutations happen in its single `Run()` goroutine, so no locks. Each `Client` -has a `readPump` and `writePump`. Messages are JSON tagged by `type` -(`chat` / `system` / `history`). To add a new realtime feature (e.g. the canvas), -add a `type` and handle it in the hub + client — no new connection plumbing. - -## Run it - -```sh -go mod download -go run . --server --migrate -# -> http://localhost:8080 -``` - -Flags: `--server`, `--migrate`, `--port 8080`, -`--database-path ./db/penguins.db`, `--template-path ./templates`, -`--static-path ./static`. - -### Docker - -```sh -docker compose up --build -``` - -## Roadmap - -- [x] base framework + live chat over websockets -- [x] MAC-based accounts + portal with reclaim -- [ ] r/place-style shared canvas -- [ ] presence / who's online -- [ ] more LAN games diff --git a/api/auth/auth.go b/api/auth/auth.go index a70af32..78123a6 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -141,11 +141,20 @@ func RenameContinuation(context *types.RequestContext, req *http.Request, resp h return renameError("something broke, try again") } + oldName := context.User.Username if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil { log.Println("rename failed:", err) return renameError("couldn't rename, try again") } + // Device records are synthesized from the current username and follow + // the rename for free; stored records have the old name baked in, so + // move them to the new subdomain to keep the user's setup working. + zone := context.Args.DnsZone + if err := database.RenameUserDNSDomain(context.DBConn, context.User.ID, oldName+"."+zone, newName+"."+zone); err != nil { + log.Println("rename dns records failed:", err) + } + context.User.Username = newName context.Nick = newName http.Redirect(resp, req, "/", http.StatusSeeOther) @@ -272,6 +281,12 @@ func bindAndFinish(context *types.RequestContext, resp http.ResponseWriter, user if err := database.AssociateMAC(context.DBConn, mac, user.ID); err != nil { log.Println("associate mac failed:", err) } + // First device a user binds becomes their primary automatically. The + // conditional update is atomic, so this stays correct even if two + // devices bind concurrently. + if err := database.SetPrimaryMACIfUnset(context.DBConn, user.ID, mac); err != nil { + log.Println("set primary mac failed:", err) + } } _ = database.TouchUser(context.DBConn, user.ID) if err := LoginUser(context, resp, user); err != nil { diff --git a/api/canvas/canvas.go b/api/canvas/canvas.go index 717d924..d44dd87 100644 --- a/api/canvas/canvas.go +++ b/api/canvas/canvas.go @@ -1,14 +1,13 @@ -// Package canvas is a single shared r/place-style grid. The append-only pixel -// log in the database is the source of truth; periodic binary snapshots let a -// new client load the current state without replaying all of history. package canvas import ( + "compress/gzip" "database/sql" "encoding/json" "fmt" "log" "net/http" + "strings" "time" "github.com/gorilla/websocket" @@ -18,12 +17,12 @@ import ( ) const ( - Rows = 64 - Cols = 64 + Rows = 512 + Cols = 512 Scale = 8 - placeCooldown = 2 * time.Second - snapshotThreshold = 50 + placeCooldown = 500 * time.Millisecond + snapshotThreshold = 500 defaultColor = 0xffffff writeWait = 10 * time.Second @@ -32,7 +31,6 @@ const ( maxMessageSize = 256 ) -// r/place 2017 palette. var palette = []int{ 0xffffff, 0xe4e4e4, 0x888888, 0x222222, 0xffa7d1, 0xe50000, 0xe59500, 0xa06a42, @@ -61,8 +59,6 @@ type outbound struct { Ms int64 `json:"ms,omitempty"` } -// ---- snapshot / state ------------------------------------------------------ - func blankCanvas() []byte { buf := make([]byte, Rows*Cols*3) for i := 0; i < len(buf); i += 3 { @@ -83,7 +79,6 @@ func setPixel(buf []byte, x, y, color int) { buf[i+2] = byte(color) } -// baseline returns the latest snapshot's bytes and through-id, or a blank canvas. func baseline(db *sql.DB) ([]byte, int64, error) { snap, err := database.LatestCanvasSnapshot(db) if err != nil { @@ -138,8 +133,6 @@ func takeSnapshot(db *sql.DB) error { return database.DeleteOldCanvasSnapshots(db) } -// ---- hub ------------------------------------------------------------------- - type Hub struct { db *sql.DB clients map[*Client]bool @@ -246,8 +239,6 @@ func (h *Hub) handlePlace(p placement) { } } -// ---- client ---------------------------------------------------------------- - type Client struct { hub *Hub conn *websocket.Conn @@ -308,8 +299,6 @@ func (c *Client) writePump() { } } -// ---- continuations --------------------------------------------------------- - type pageConfig struct { Rows int Cols int @@ -348,7 +337,15 @@ func StateContinuation(context *types.RequestContext, req *http.Request, resp ht return success(context, req, resp) } resp.Header().Set("Content-Type", "application/octet-stream") - resp.Write(buf) + // mostly-blank canvas, so it gzips down to almost nothing. + if strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") { + resp.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(resp) + defer gz.Close() + gz.Write(buf) + } else { + resp.Write(buf) + } return success(context, req, resp) } } diff --git a/api/canvas/canvas_test.go b/api/canvas/canvas_test.go index 324a3a1..38bb492 100644 --- a/api/canvas/canvas_test.go +++ b/api/canvas/canvas_test.go @@ -53,7 +53,6 @@ func TestSnapshotBakesThenReplays(t *testing.T) { if err := takeSnapshot(db); err != nil { t.Fatal(err) } - // a placement after the snapshot must be replayed on top of it if _, err := database.SavePixel(db, "u", 6, 6, 0x02be01); err != nil { t.Fatal(err) } @@ -69,7 +68,6 @@ func TestSnapshotBakesThenReplays(t *testing.T) { t.Errorf("replayed pixel = %06x, want green", got) } - // snapshots are kept to one row if err := takeSnapshot(db); err != nil { t.Fatal(err) } diff --git a/api/devices/devices.go b/api/devices/devices.go new file mode 100644 index 0000000..227afcb --- /dev/null +++ b/api/devices/devices.go @@ -0,0 +1,92 @@ +package devices + +import ( + "log" + "net/http" + + "penguins.lan/api/types" + "penguins.lan/database" + "penguins.lan/utils" +) + +// DeviceView is one device row for the profile page. +type DeviceView struct { + Mac string // canonical colon form, used as the set-primary value + Name string // .. FQDN + IP string + Online bool + LastSeen string + Primary bool +} + +// ListDevicesContinuation loads the user's devices (with liveness + which is +// primary) and the zone facts the profile page and wizard need. +func ListDevicesContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + if context.User == nil { + return failure(context, req, resp) + } + + macs, err := database.ListUserMacs(context.DBConn, context.User.ID) + if err != nil { + log.Println("devices: list failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + primary, err := database.GetUserPrimaryMAC(context.DBConn, context.User.ID) + if err != nil { + log.Println("devices: primary lookup failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + zone := context.Args.DnsZone + userDomain := context.User.Username + "." + zone + + views := make([]DeviceView, 0, len(macs)) + for _, d := range macs { + name := d.Mac + if mac, err := utils.ParseMac(d.Mac); err == nil { + name = mac.Compact() + "." + userDomain + } + views = append(views, DeviceView{ + Mac: d.Mac, + Name: name, + IP: d.IP, + Online: d.Online, + LastSeen: utils.FormatSince(d.LastSeen), + Primary: d.Mac == primary, + }) + } + + (*context.TemplateData)["Devices"] = views + (*context.TemplateData)["PrimaryMAC"] = primary + (*context.TemplateData)["DnsZone"] = zone + (*context.TemplateData)["UserDomain"] = userDomain + return success(context, req, resp) + } +} + +// SetPrimaryContinuation marks one of the caller's devices as primary. +func SetPrimaryContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + mac := req.FormValue("mac") + owned, err := database.MACBelongsToUser(context.DBConn, mac, context.User.ID) + if err != nil { + log.Println("devices: ownership check failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + if !owned { + resp.WriteHeader(http.StatusUnauthorized) + return failure(context, req, resp) + } + if err := database.SetUserPrimaryMAC(context.DBConn, context.User.ID, mac); err != nil { + log.Println("devices: set primary failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + http.Redirect(resp, req, "/profile", http.StatusSeeOther) + return success(context, req, resp) + } +} diff --git a/api/dns/dns.go b/api/dns/dns.go new file mode 100644 index 0000000..4cda557 --- /dev/null +++ b/api/dns/dns.go @@ -0,0 +1,147 @@ +package dns + +import ( + "log" + "net/http" + "strconv" + "strings" + + "penguins.lan/api/types" + "penguins.lan/database" + "penguins.lan/utils" +) + +const MaxUserRecords = 50 + +var allowedTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "TXT": true} + +// ListDNSRecordsContinuation loads the current user's records for the editor. +func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + if context.User == nil { + return failure(context, req, resp) + } + records, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) + if err != nil { + log.Println("dns: list records failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + (*context.TemplateData)["DNSRecords"] = records + return success(context, req, resp) + } +} + +// CreateDNSRecordContinuation validates and upserts a record. Names are confined +// to the user's own . subtree; bare/relative names are expanded +// to it for convenience. +func CreateDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + fail := func(msg string, code int) types.ContinuationChain { + resp.WriteHeader(code) + (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{msg}} + return failure(context, req, resp) + } + + recordType := strings.ToUpper(strings.TrimSpace(req.FormValue("type"))) + if !allowedTypes[recordType] { + return fail("type must be one of A, AAAA, CNAME, TXT", http.StatusBadRequest) + } + + name, ok := qualifyName(req.FormValue("name"), context.User.Username, context.Args.DnsZone) + if !ok { + return fail("name must be under "+context.User.Username+"."+context.Args.DnsZone, http.StatusBadRequest) + } + + content := strings.TrimSpace(req.FormValue("content")) + if content == "" { + return fail("content can't be empty", http.StatusBadRequest) + } + + ttl := 300 + if raw := strings.TrimSpace(req.FormValue("ttl")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return fail("ttl must be a non-negative number", http.StatusBadRequest) + } + ttl = n + } + + count, err := database.CountUserDNSRecords(context.DBConn, context.User.ID) + if err != nil { + return fail("something broke, try again", http.StatusInternalServerError) + } + if count >= MaxUserRecords { + return fail("you've hit the record limit", http.StatusTooManyRequests) + } + + // A name may hold at most one CNAME; replace any existing one so + // repointing the primary (or re-running the wizard) doesn't duplicate. + if recordType == "CNAME" { + if err := database.DeleteUserRecordsByNameType(context.DBConn, context.User.ID, name, "CNAME"); err != nil { + return fail("something broke, try again", http.StatusInternalServerError) + } + } + + record := &database.DNSRecord{ + ID: utils.RandomId(), + UserID: context.User.ID, + Name: name, + Type: recordType, + Content: content, + TTL: ttl, + } + if _, err := database.SaveDNSRecord(context.DBConn, record); err != nil { + log.Println("dns: save record failed:", err) + return fail("couldn't save that record", http.StatusInternalServerError) + } + + http.Redirect(resp, req, "/profile", http.StatusSeeOther) + return success(context, req, resp) + } +} + +// DeleteDNSRecordContinuation removes a record the caller owns. +func DeleteDNSRecordContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + record, err := database.GetDNSRecord(context.DBConn, req.FormValue("id")) + if err != nil { + resp.WriteHeader(http.StatusNotFound) + return failure(context, req, resp) + } + if record.UserID != context.User.ID { + resp.WriteHeader(http.StatusUnauthorized) + return failure(context, req, resp) + } + if err := database.DeleteDNSRecord(context.DBConn, record.ID); err != nil { + log.Println("dns: delete record failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + http.Redirect(resp, req, "/profile", http.StatusSeeOther) + return success(context, req, resp) + } +} + +// qualifyName normalizes a record name to a fully-qualified name (trailing dot) +// confined to the user's . subtree. A name already under the +// subtree is kept; a name elsewhere in the zone is rejected; anything else is +// treated as relative to the subtree (so "www" -> "www..."). +func qualifyName(raw, username, zone string) (string, bool) { + raw = strings.ToLower(strings.TrimSpace(raw)) + raw = strings.TrimSuffix(raw, ".") + if raw == "" { + return "", false + } + zone = strings.ToLower(zone) + userDomain := username + "." + zone + + switch { + case raw == userDomain || strings.HasSuffix(raw, "."+userDomain): + return raw + ".", true + case raw == zone || strings.HasSuffix(raw, "."+zone): + return "", false // in the zone but not the user's subtree + default: + return raw + "." + userDomain + ".", true + } +} diff --git a/api/serve.go b/api/serve.go index 0005369..dad5009 100644 --- a/api/serve.go +++ b/api/serve.go @@ -9,6 +9,8 @@ import ( "penguins.lan/api/auth" "penguins.lan/api/canvas" + "penguins.lan/api/devices" + "penguins.lan/api/dns" "penguins.lan/api/minecraft" "penguins.lan/api/presence" "penguins.lan/api/template" @@ -99,7 +101,26 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { mux.HandleFunc("GET /profile", func(w http.ResponseWriter, r *http.Request) { ctx := makeRequestContext() - LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), IdContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /profile/primary", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.SetPrimaryContinuation, auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /dns", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + }) + + mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, auth.GoPortalContinuation)(dns.CreateDNSRecordContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /dns/delete", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(devices.ListDevicesContinuation, auth.GoPortalContinuation)(dns.ListDNSRecordsContinuation, auth.GoPortalContinuation)(dns.DeleteDNSRecordContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) mux.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) { @@ -109,7 +130,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { ctx := makeRequestContext() - LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("home.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), FailurePassingContinuation)(canvas.PageContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("home.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) mux.HandleFunc("POST /rename", func(w http.ResponseWriter, r *http.Request) { @@ -122,11 +143,6 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(presence.StatusContinuation(hub), auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) - mux.HandleFunc("GET /canvas", func(w http.ResponseWriter, r *http.Request) { - ctx := makeRequestContext() - LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(canvas.PageContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("canvas.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) - }) - mux.HandleFunc("GET /canvas/state", func(w http.ResponseWriter, r *http.Request) { ctx := makeRequestContext() LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(canvas.StateContinuation, auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) diff --git a/args/args.go b/args/args.go index 6655f4a..6c79fe9 100644 --- a/args/args.go +++ b/args/args.go @@ -3,6 +3,7 @@ package args import ( "flag" "os" + "strings" ) type Arguments struct { @@ -16,6 +17,17 @@ type Arguments struct { Port int Server bool + // Dns runs the authoritative DNS server for the LAN. DnsZone is the suffix + // it owns (e.g. penguins.lan); queries outside it are forwarded to + // DnsResolvers (empty = no forwarding, since the LAN has no outside + // internet). ServerIP (env SERVER_IP) answers the zone apex so the website + // itself resolves; empty leaves the apex unanswered. + Dns bool + DnsPort int + DnsResolvers []string + DnsZone string + ServerIP string + // DevMAC (env DEV_MAC) forces every client's MAC, for testing auth from // loopback where the browser never appears in the ARP table. DevMAC string @@ -40,8 +52,20 @@ func GetArgs() (*Arguments, error) { port := flag.Int("port", 8080, "Port to listen on") server := flag.Bool("server", false, "Run the HTTP server") + dns := flag.Bool("dns", false, "Run the authoritative DNS server") + dnsPort := flag.Int("dns-port", 8053, "Port for the DNS server to listen on") + dnsZone := flag.String("dns-zone", "penguins.lan", "DNS zone this server is authoritative for") + dnsResolvers := flag.String("dns-resolvers", "", "Comma-separated upstream resolvers for non-zone queries (empty disables forwarding)") + flag.Parse() + resolvers := []string{} + for _, r := range strings.Split(*dnsResolvers, ",") { + if r = strings.TrimSpace(r); r != "" { + resolvers = append(resolvers, r) + } + } + return &Arguments{ DatabasePath: *databasePath, TemplatePath: *templatePath, @@ -50,6 +74,11 @@ func GetArgs() (*Arguments, error) { Scheduler: *scheduler, Port: *port, Server: *server, + Dns: *dns, + DnsPort: *dnsPort, + DnsResolvers: resolvers, + DnsZone: *dnsZone, + ServerIP: os.Getenv("SERVER_IP"), DevMAC: os.Getenv("DEV_MAC"), RconAddress: os.Getenv("RCON_ADDRESS"), RconPassword: os.Getenv("RCON_PASSWORD"), diff --git a/database/connections.go b/database/connections.go index e16a17e..1af9de7 100644 --- a/database/connections.go +++ b/database/connections.go @@ -60,6 +60,22 @@ func MacForIP(db *sql.DB, ip string) (string, error) { return mac, err } +// IPForMac returns the freshest live IP for a MAC, or "" if the device hasn't +// been seen within the freshness window. This is what the DNS server answers a +// device's A query with. A MAC can briefly hold more than one IP (a lease +// change mid-window), so the most recently updated entry wins. +func IPForMac(db *sql.DB, mac string) (string, error) { + var ip string + err := db.QueryRow( + "SELECT ip FROM connections WHERE mac = ? AND updated_at > ? ORDER BY updated_at DESC LIMIT 1", + mac, time.Now().Add(-connectionFreshness), + ).Scan(&ip) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return ip, err +} + // NetworkUserIDs returns the set of users whose MAC was seen on the LAN within // the freshness window, whether or not they're on the chat. func NetworkUserIDs(db *sql.DB) (map[string]bool, error) { diff --git a/database/devices.go b/database/devices.go new file mode 100644 index 0000000..ab2a372 --- /dev/null +++ b/database/devices.go @@ -0,0 +1,64 @@ +package database + +import ( + "database/sql" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// UserDevice is one of a user's MACs joined to its current liveness from the +// ARP table: IP and Online are populated only when the device was seen within +// the freshness window. +type UserDevice struct { + Mac string + IP string + Online bool + LastSeen time.Time +} + +// MACOwnedByUsername reports whether a MAC is bound to the user with the given +// username. Username matching is case-insensitive to suit DNS labels. This +// gates the resolver's dynamic . device records. +func MACOwnedByUsername(db *sql.DB, mac, username string) (bool, error) { + var one int + err := db.QueryRow(`SELECT 1 FROM macs m JOIN users u ON u.id = m.user_id + WHERE m.mac = ? AND u.username = ? COLLATE NOCASE`, mac, username).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +// ListUserMacs returns every MAC bound to a user, newest-seen first, annotated +// with its current LAN IP when the device is live. +func ListUserMacs(db *sql.DB, userID string) ([]UserDevice, error) { + fresh := time.Now().Add(-connectionFreshness) + rows, err := db.Query(` + SELECT m.mac, m.last_seen, + (SELECT c.ip FROM connections c + WHERE c.mac = m.mac AND c.updated_at > ? + ORDER BY c.updated_at DESC LIMIT 1) AS ip + FROM macs m + WHERE m.user_id = ? + ORDER BY m.last_seen DESC`, + fresh, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + devices := []UserDevice{} + for rows.Next() { + var d UserDevice + var ip sql.NullString + if err := rows.Scan(&d.Mac, &d.LastSeen, &ip); err != nil { + return nil, err + } + d.IP = ip.String + d.Online = ip.Valid + devices = append(devices, d) + } + return devices, rows.Err() +} diff --git a/database/dns.go b/database/dns.go new file mode 100644 index 0000000..8372936 --- /dev/null +++ b/database/dns.go @@ -0,0 +1,157 @@ +package database + +import ( + "database/sql" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// DNSRecord is a user-owned record in the LAN zone. Names are stored fully +// qualified with a trailing dot (e.g. "tux.penguins.lan.") to match the form +// the resolver queries with. +type DNSRecord struct { + ID string + UserID string + Name string + Type string + Content string + TTL int + CreatedAt time.Time +} + +const dnsColumns = "id, user_id, name, type, content, ttl, created_at" + +func scanDNSRecord(s interface{ Scan(...any) error }) (*DNSRecord, error) { + var r DNSRecord + if err := s.Scan(&r.ID, &r.UserID, &r.Name, &r.Type, &r.Content, &r.TTL, &r.CreatedAt); err != nil { + return nil, err + } + return &r, nil +} + +func CountUserDNSRecords(db *sql.DB, userID string) (int, error) { + var count int + err := db.QueryRow("SELECT COUNT(*) FROM dns_records WHERE user_id = ?", userID).Scan(&count) + return count, err +} + +func GetUserDNSRecords(db *sql.DB, userID string) ([]DNSRecord, error) { + rows, err := db.Query("SELECT "+dnsColumns+" FROM dns_records WHERE user_id = ? ORDER BY name, type", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + records := []DNSRecord{} + for rows.Next() { + r, err := scanDNSRecord(rows) + if err != nil { + return nil, err + } + records = append(records, *r) + } + return records, rows.Err() +} + +// SaveDNSRecord upserts a record. The unique (name, type, content) index means +// re-saving the same record is a no-op rather than a duplicate. +func SaveDNSRecord(db *sql.DB, record *DNSRecord) (*DNSRecord, error) { + if record.CreatedAt.IsZero() { + record.CreatedAt = time.Now() + } + _, err := db.Exec( + "INSERT OR REPLACE INTO dns_records ("+dnsColumns+") VALUES (?, ?, ?, ?, ?, ?, ?)", + record.ID, record.UserID, record.Name, record.Type, record.Content, record.TTL, record.CreatedAt, + ) + if err != nil { + return nil, err + } + return record, nil +} + +func GetDNSRecord(db *sql.DB, recordID string) (*DNSRecord, error) { + r, err := scanDNSRecord(db.QueryRow("SELECT "+dnsColumns+" FROM dns_records WHERE id = ?", recordID)) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return r, err +} + +func DeleteDNSRecord(db *sql.DB, recordID string) error { + _, err := db.Exec("DELETE FROM dns_records WHERE id = ?", recordID) + return err +} + +// DeleteUserRecordsByNameType removes a user's records at a name of a given +// type. Used to keep a single CNAME per name (so re-running the wizard or +// repointing the primary replaces rather than duplicates). +func DeleteUserRecordsByNameType(db *sql.DB, userID, name, qtype string) error { + _, err := db.Exec("DELETE FROM dns_records WHERE user_id = ? AND name = ? AND type = ?", userID, name, qtype) + return err +} + +// RenameUserDNSDomain moves a user's records from oldDomain to newDomain (both +// of the form ".", no trailing dot) when their handle changes, +// rewriting the suffix in both record names and contents so their setup keeps +// working under the new name. Records that don't reference the domain are left +// alone. +func RenameUserDNSDomain(db *sql.DB, userID, oldDomain, newDomain string) error { + records, err := GetUserDNSRecords(db, userID) + if err != nil { + return err + } + for _, rec := range records { + newName := rewriteDomainSuffix(rec.Name, oldDomain, newDomain) + newContent := rewriteDomainSuffix(rec.Content, oldDomain, newDomain) + if newName == rec.Name && newContent == rec.Content { + continue + } + rec.Name, rec.Content = newName, newContent + if _, err := SaveDNSRecord(db, &rec); err != nil { + return err + } + } + return nil +} + +// rewriteDomainSuffix replaces a trailing oldDomain (the whole string or a +// dot-separated suffix) with newDomain, preserving any trailing dot. A value +// that merely contains oldDomain mid-string is left untouched. +func rewriteDomainSuffix(s, oldDomain, newDomain string) string { + hadDot := strings.HasSuffix(s, ".") + bare := strings.TrimSuffix(s, ".") + switch { + case bare == oldDomain: + bare = newDomain + case strings.HasSuffix(bare, "."+oldDomain): + bare = strings.TrimSuffix(bare, "."+oldDomain) + "." + newDomain + default: + return s + } + if hadDot { + return bare + "." + } + return bare +} + +// FindDNSRecords returns every record matching a fully-qualified name and type. +// This is the resolver's lookup path. +func FindDNSRecords(db *sql.DB, name, qtype string) ([]DNSRecord, error) { + rows, err := db.Query("SELECT "+dnsColumns+" FROM dns_records WHERE name = ? AND type = ?", name, qtype) + if err != nil { + return nil, err + } + defer rows.Close() + + records := []DNSRecord{} + for rows.Next() { + r, err := scanDNSRecord(rows) + if err != nil { + return nil, err + } + records = append(records, *r) + } + return records, rows.Err() +} diff --git a/database/dns_test.go b/database/dns_test.go new file mode 100644 index 0000000..a893021 --- /dev/null +++ b/database/dns_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func dnsTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "dns.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := Migrate(db); err != nil { + t.Fatal(err) + } + return db +} + +func TestRenameUserDNSDomain(t *testing.T) { + db := dnsTestDB(t) + if _, err := CreateUser(db, "u1", "tux"); err != nil { + t.Fatal(err) + } + + records := []*DNSRecord{ + {ID: "a", UserID: "u1", Name: "tux.penguins.lan.", Type: "CNAME", Content: "aabbccddeeff.tux.penguins.lan", TTL: 300}, + {ID: "b", UserID: "u1", Name: "www.tux.penguins.lan.", Type: "A", Content: "192.168.8.9", TTL: 60}, + // content that merely contains the domain mid-string must NOT be rewritten + {ID: "c", UserID: "u1", Name: "note.tux.penguins.lan.", Type: "TXT", Content: "notmytux.penguins.lan", TTL: 60}, + } + for _, r := range records { + if _, err := SaveDNSRecord(db, r); err != nil { + t.Fatal(err) + } + } + + if err := RenameUserDNSDomain(db, "u1", "tux.penguins.lan", "gentoo.penguins.lan"); err != nil { + t.Fatal(err) + } + + got, err := GetUserDNSRecords(db, "u1") + if err != nil { + t.Fatal(err) + } + byID := map[string]DNSRecord{} + for _, r := range got { + byID[r.ID] = r + } + + if byID["a"].Name != "gentoo.penguins.lan." || byID["a"].Content != "aabbccddeeff.gentoo.penguins.lan" { + t.Errorf("apex CNAME = %q -> %q, want gentoo + device target", byID["a"].Name, byID["a"].Content) + } + if byID["b"].Name != "www.gentoo.penguins.lan." || byID["b"].Content != "192.168.8.9" { + t.Errorf("A record = %q -> %q, want www.gentoo + unchanged ip", byID["b"].Name, byID["b"].Content) + } + if byID["c"].Name != "note.gentoo.penguins.lan." || byID["c"].Content != "notmytux.penguins.lan" { + t.Errorf("TXT = %q -> %q, want name moved but content untouched", byID["c"].Name, byID["c"].Content) + } +} diff --git a/database/migrate.go b/database/migrate.go index 24d47b6..13b2cb1 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "log" + "strings" _ "github.com/mattn/go-sqlite3" ) @@ -15,6 +16,7 @@ func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) { _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, + primary_mac TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`) @@ -26,6 +28,46 @@ func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) { return dbConn, err } +// MigrateUserPrimaryMac adds primary_mac to pre-existing users tables. It's +// idempotent: SQLite has no "ADD COLUMN IF NOT EXISTS", so the duplicate-column +// error (column already present, e.g. on a fresh DB) is swallowed. +func MigrateUserPrimaryMac(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating users.primary_mac column") + + _, err := dbConn.Exec(`ALTER TABLE users ADD COLUMN primary_mac TEXT;`) + if err != nil && !strings.Contains(err.Error(), "duplicate column name") { + return dbConn, err + } + return dbConn, nil +} + +// MigrateDNSRecords holds the user-editable DNS records served for the LAN zone. +func MigrateDNSRecords(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating dns_records table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS dns_records ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + content TEXT NOT NULL, + ttl INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + );`) + if err != nil { + return dbConn, err + } + + _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dns_records_name_content_type ON dns_records (name, type, content);`) + if err != nil { + return dbConn, err + } + + _, err = dbConn.Exec(`CREATE INDEX IF NOT EXISTS idx_dns_records_name_type ON dns_records (name, type);`) + return dbConn, err +} + // MigrateMacs maps hardware addresses to users. One user can own many MACs — // that's how a privacy-rotated device "logs back in" to an existing account. func MigrateMacs(dbConn *sql.DB) (*sql.DB, error) { @@ -124,12 +166,14 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) { migrations := []Migrator{ MigrateUsers, + MigrateUserPrimaryMac, MigrateMacs, MigrateSessions, MigrateChat, MigrateConnections, MigrateMinecraft, MigrateCanvas, + MigrateDNSRecords, } for _, migration := range migrations { diff --git a/database/users.go b/database/users.go index 169cce1..164a631 100644 --- a/database/users.go +++ b/database/users.go @@ -99,3 +99,44 @@ func AssociateMAC(db *sql.DB, mac, userID string) error { mac, userID, now, now) return err } + +// MACBelongsToUser reports whether a MAC is currently bound to the given user. +func MACBelongsToUser(db *sql.DB, mac, userID string) (bool, error) { + var owner string + err := db.QueryRow("SELECT user_id FROM macs WHERE mac = ?", mac).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return owner == userID, nil +} + +// SetUserPrimaryMAC sets the user's primary device (the MAC its +// . name points at). +func SetUserPrimaryMAC(db *sql.DB, userID, mac string) error { + _, err := db.Exec("UPDATE users SET primary_mac = ? WHERE id = ?", mac, userID) + return err +} + +// SetPrimaryMACIfUnset atomically makes mac the primary device only when the +// user has none yet. The conditional WHERE makes "first device wins" race-free: +// concurrent associations can't both win, and there's no read-then-write gap. +func SetPrimaryMACIfUnset(db *sql.DB, userID, mac string) error { + _, err := db.Exec("UPDATE users SET primary_mac = ? WHERE id = ? AND primary_mac IS NULL", mac, userID) + return err +} + +// GetUserPrimaryMAC returns the user's primary MAC, or "" if unset. +func GetUserPrimaryMAC(db *sql.DB, userID string) (string, error) { + var mac sql.NullString + err := db.QueryRow("SELECT primary_mac FROM users WHERE id = ?", userID).Scan(&mac) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + return mac.String, nil +} diff --git a/db/.gitkeep b/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dnsd/server.go b/dnsd/server.go new file mode 100644 index 0000000..295431e --- /dev/null +++ b/dnsd/server.go @@ -0,0 +1,201 @@ +// Package dnsd is the LAN's authoritative DNS server. It answers the configured +// zone (e.g. penguins.lan) from user-owned records plus dynamically-synthesized +// per-device records, and forwards anything outside the zone to upstream +// resolvers. Modeled on the sibling hatecomputers.club hcdns server. +package dnsd + +import ( + "database/sql" + "fmt" + "log" + "strings" + + "github.com/miekg/dns" + + "penguins.lan/args" + "penguins.lan/database" + "penguins.lan/utils" +) + +const maxRecursion = 10 + +type Handler struct { + DB *sql.DB + Zone string // fqdn, e.g. "penguins.lan." + ServerIP string // apex A target; "" leaves the apex unanswered + Resolvers []string +} + +// resolveExternal forwards a query to the first upstream resolver that answers. +// With no resolvers configured (the LAN has no outside internet) it returns +// nothing. +func (h *Handler) resolveExternal(name string, qtype uint16) ([]dns.RR, error) { + if len(h.Resolvers) == 0 { + return nil, nil + } + client := &dns.Client{} + msg := &dns.Msg{} + msg.SetQuestion(name, qtype) + msg.RecursionDesired = true + + var lastErr error + for _, resolver := range h.Resolvers { + in, _, err := client.Exchange(msg, resolver) + if err != nil { + lastErr = err + continue + } + return in.Answer, nil + } + return nil, lastErr +} + +// synthesizeDevice answers .. with the device's +// current LAN IP, if the label is a MAC owned by that user and the device is +// live. These records aren't stored — they track DHCP in real time. +func (h *Handler) synthesizeDevice(name string, qtype uint16) ([]dns.RR, error) { + if qtype != dns.TypeA { + return nil, nil + } + rest, ok := strings.CutSuffix(name, "."+h.Zone) + if !ok { + return nil, nil + } + labels := strings.Split(rest, ".") + if len(labels) != 2 { + return nil, nil // only . lives directly under the zone + } + macLabel, username := labels[0], labels[1] + + mac, err := utils.ParseMacCompact(macLabel) + if err != nil { + return nil, nil // not a MAC label + } + owned, err := database.MACOwnedByUsername(h.DB, mac.Format(), username) + if err != nil { + return nil, err + } + if !owned { + return nil, nil + } + ip, err := database.IPForMac(h.DB, mac.Format()) + if err != nil || ip == "" { + return nil, err + } + rr, err := dns.NewRR(fmt.Sprintf("%s 30 IN A %s", name, ip)) + if err != nil { + return nil, err + } + return []dns.RR{rr}, nil +} + +// resolveLocal answers an in-zone name: stored CNAMEs (followed recursively), +// then stored records of the requested type, then the apex and dynamic device +// records as fallbacks. +func (h *Handler) resolveLocal(name string, qtype uint16, depth int) ([]dns.RR, error) { + if depth == 0 { + return nil, fmt.Errorf("dns: too much recursion resolving %s", name) + } + + var answers []dns.RR + + cnames, err := database.FindDNSRecords(h.DB, name, "CNAME") + if err != nil { + return nil, err + } + for _, rec := range cnames { + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN CNAME %s", rec.Name, rec.TTL, dns.Fqdn(rec.Content))) + if err != nil { + return nil, err + } + answers = append(answers, rr) + + if qtype != dns.TypeCNAME { + followed, err := h.resolveLocal(dns.Fqdn(rec.Content), qtype, depth-1) + if err != nil { + return nil, err + } + answers = append(answers, followed...) + } + } + + qtypeName := dns.TypeToString[qtype] + records, err := database.FindDNSRecords(h.DB, name, qtypeName) + if err != nil { + return nil, err + } + for _, rec := range records { + rr, err := dns.NewRR(fmt.Sprintf("%s %d IN %s %s", rec.Name, rec.TTL, qtypeName, rec.Content)) + if err != nil { + return nil, err + } + answers = append(answers, rr) + } + + if len(answers) > 0 { + return answers, nil + } + + if name == h.Zone && qtype == dns.TypeA && h.ServerIP != "" { + rr, err := dns.NewRR(fmt.Sprintf("%s 300 IN A %s", name, h.ServerIP)) + if err != nil { + return nil, err + } + return []dns.RR{rr}, nil + } + + return h.synthesizeDevice(name, qtype) +} + +func (h *Handler) inZone(name string) bool { + return name == h.Zone || strings.HasSuffix(name, "."+h.Zone) +} + +func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + msg := &dns.Msg{} + msg.SetReply(r) + + for _, q := range r.Question { + name := dns.Fqdn(strings.ToLower(q.Name)) + if h.inZone(name) { + msg.Authoritative = true + answers, err := h.resolveLocal(name, q.Qtype, maxRecursion) + if err != nil { + log.Println("dnsd: resolve failed:", err) + msg.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(msg) + return + } + msg.Answer = append(msg.Answer, answers...) + } else { + answers, err := h.resolveExternal(name, q.Qtype) + if err != nil { + log.Println("dnsd: forward failed:", err) + msg.SetRcode(r, dns.RcodeServerFailure) + w.WriteMsg(msg) + return + } + msg.Answer = append(msg.Answer, answers...) + } + } + + if len(msg.Answer) == 0 && msg.Authoritative { + msg.SetRcode(r, dns.RcodeNameError) + } + w.WriteMsg(msg) +} + +func MakeServer(argv *args.Arguments, db *sql.DB) *dns.Server { + handler := &Handler{ + DB: db, + Zone: dns.Fqdn(argv.DnsZone), + ServerIP: argv.ServerIP, + Resolvers: argv.DnsResolvers, + } + return &dns.Server{ + Addr: fmt.Sprintf(":%d", argv.DnsPort), + Net: "udp", + Handler: handler, + UDPSize: 65535, + } +} diff --git a/dnsd/server_test.go b/dnsd/server_test.go new file mode 100644 index 0000000..a9cb726 --- /dev/null +++ b/dnsd/server_test.go @@ -0,0 +1,172 @@ +package dnsd + +import ( + "database/sql" + "net" + "path/filepath" + "testing" + + "github.com/miekg/dns" + + "penguins.lan/database" + "penguins.lan/utils" +) + +const ( + testUser = "tux" + testMac = "aa:bb:cc:dd:ee:ff" + testMacLbl = "aabbccddeeff" + testIP = "192.168.8.50" + testZone = "penguins.lan." + testApexIP = "10.0.0.1" + testDevName = testMacLbl + "." + testUser + "." + testZone +) + +// capWriter captures the reply a Handler writes instead of sending it. +type capWriter struct{ msg *dns.Msg } + +func (c *capWriter) WriteMsg(m *dns.Msg) error { c.msg = m; return nil } +func (c *capWriter) Write([]byte) (int, error) { return 0, nil } +func (c *capWriter) Close() error { return nil } +func (c *capWriter) TsigStatus() error { return nil } +func (c *capWriter) TsigTimersOnly(bool) {} +func (c *capWriter) Hijack() {} +func (c *capWriter) LocalAddr() net.Addr { return &net.UDPAddr{} } +func (c *capWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} } + +func testDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "d.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := database.Migrate(db); err != nil { + t.Fatal(err) + } + return db +} + +func testHandler(db *sql.DB) *Handler { + return &Handler{DB: db, Zone: testZone, ServerIP: testApexIP} +} + +// query runs one question through the handler and returns the reply. +func query(t *testing.T, h *Handler, name string, qtype uint16) *dns.Msg { + t.Helper() + m := &dns.Msg{} + m.SetQuestion(dns.Fqdn(name), qtype) + w := &capWriter{} + h.ServeDNS(w, m) + if w.msg == nil { + t.Fatal("handler wrote no reply") + } + return w.msg +} + +// seedDevice creates the user, binds the MAC, and marks it live at testIP. +func seedDevice(t *testing.T, db *sql.DB) { + t.Helper() + user, err := database.CreateUser(db, "u1", testUser) + if err != nil { + t.Fatal(err) + } + if err := database.AssociateMAC(db, testMac, user.ID); err != nil { + t.Fatal(err) + } + if err := database.RefreshConnections(db, map[string]string{testIP: testMac}); err != nil { + t.Fatal(err) + } +} + +func TestDeviceRecordSynthesized(t *testing.T) { + db := testDB(t) + seedDevice(t, db) + + reply := query(t, testHandler(db), testDevName, dns.TypeA) + if len(reply.Answer) != 1 { + t.Fatalf("answers = %d, want 1: %v", len(reply.Answer), reply.Answer) + } + a, ok := reply.Answer[0].(*dns.A) + if !ok || a.A.String() != testIP { + t.Fatalf("device A = %v, want %s", reply.Answer[0], testIP) + } +} + +func TestPrimaryCNAMEFollowsToDeviceA(t *testing.T) { + db := testDB(t) + seedDevice(t, db) + + // the wizard stores this: .zone CNAME -> ..zone + if _, err := database.SaveDNSRecord(db, &database.DNSRecord{ + ID: "r1", UserID: "u1", Name: testUser + "." + testZone, + Type: "CNAME", Content: testDevName, TTL: 300, + }); err != nil { + t.Fatal(err) + } + + reply := query(t, testHandler(db), testUser+"."+testZone, dns.TypeA) + var sawCNAME, sawA bool + for _, rr := range reply.Answer { + switch v := rr.(type) { + case *dns.CNAME: + sawCNAME = v.Target == testDevName + case *dns.A: + sawA = v.A.String() == testIP + } + } + if !sawCNAME || !sawA { + t.Fatalf("want CNAME->device + A=%s, got %v", testIP, reply.Answer) + } +} + +func TestApexAnswersServerIP(t *testing.T) { + db := testDB(t) + reply := query(t, testHandler(db), testZone, dns.TypeA) + if len(reply.Answer) != 1 { + t.Fatalf("apex answers = %v", reply.Answer) + } + if a, ok := reply.Answer[0].(*dns.A); !ok || a.A.String() != testApexIP { + t.Fatalf("apex A = %v, want %s", reply.Answer[0], testApexIP) + } +} + +func TestCustomTXTRecord(t *testing.T) { + db := testDB(t) + seedDevice(t, db) + name := "hello." + testUser + "." + testZone + if _, err := database.SaveDNSRecord(db, &database.DNSRecord{ + ID: "r2", UserID: "u1", Name: name, Type: "TXT", Content: "\"waddle\"", TTL: 60, + }); err != nil { + t.Fatal(err) + } + + reply := query(t, testHandler(db), name, dns.TypeTXT) + if len(reply.Answer) != 1 { + t.Fatalf("txt answers = %v", reply.Answer) + } + txt, ok := reply.Answer[0].(*dns.TXT) + if !ok || len(txt.Txt) != 1 || txt.Txt[0] != "waddle" { + t.Fatalf("txt = %v, want [waddle]", reply.Answer[0]) + } +} + +func TestUnknownNameIsNXDOMAIN(t *testing.T) { + db := testDB(t) + seedDevice(t, db) + reply := query(t, testHandler(db), "nope."+testZone, dns.TypeA) + if reply.Rcode != dns.RcodeNameError { + t.Fatalf("rcode = %d, want NXDOMAIN(%d)", reply.Rcode, dns.RcodeNameError) + } +} + +// guard: a foreign MAC label under someone else's username must not resolve. +func TestDeviceRecordRejectsForeignMAC(t *testing.T) { + db := testDB(t) + seedDevice(t, db) + other, _ := utils.ParseMac("11:22:33:44:55:66") + reply := query(t, testHandler(db), other.Compact()+"."+testUser+"."+testZone, dns.TypeA) + if reply.Rcode != dns.RcodeNameError { + t.Fatalf("foreign mac rcode = %d, want NXDOMAIN", reply.Rcode) + } +} diff --git a/go.mod b/go.mod index 15ed47a..b5540db 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,16 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.22 + github.com/miekg/dns v1.1.72 ) require ( github.com/google/uuid v1.4.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect go.uber.org/atomic v1.9.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index e75db17..cf6ff79 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -18,6 +20,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -35,6 +39,16 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go index 5d6f599..872749d 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "penguins.lan/api" "penguins.lan/args" "penguins.lan/database" + "penguins.lan/dnsd" "penguins.lan/scheduled" ) @@ -41,11 +42,24 @@ func main() { if argv.Server { server := api.MakeServer(argv, dbConn) log.Printf("listening on :%d", argv.Port) + go func() { + if err := server.ListenAndServe(); err != nil { + log.Fatal(err) + } + }() + } - if err := server.ListenAndServe(); err != nil { - log.Fatal(err) - } + if argv.Dns { + dnsServer := dnsd.MakeServer(argv, dbConn) + log.Printf("dns listening on :%d for zone %s", argv.DnsPort, argv.DnsZone) + go func() { + if err := dnsServer.ListenAndServe(); err != nil { + log.Fatal(err) + } + }() } - select {} + if argv.Scheduler || argv.Server || argv.Dns { + select {} + } } diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..b55f8c1 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "latest" diff --git a/static/css/styles.css b/static/css/styles.css index 53f7abd..d8316bb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -112,17 +112,68 @@ pre { .dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); vertical-align: middle; } .dot.chat { background: var(--ok); } .dot.network { background: var(--accent-2); } +.dot.online { background: var(--ok); } .dot.offline { background: var(--muted); } .whoami { color: var(--accent-2); font-weight: bold; } .whoami:hover { color: var(--accent); } .muted { color: var(--muted); } code { background: var(--panel); padding: .1rem .3rem; } +/* profile: devices + dns */ +.lan-table { width: 100%; border-collapse: collapse; margin: .5rem 0; } +.lan-table th { text-align: left; color: var(--muted); font-weight: normal; border-bottom: 1px dashed var(--muted); padding: .3rem .5rem; } +.lan-table td { padding: .3rem .5rem; border-bottom: 1px solid var(--panel); vertical-align: middle; } +.lan-table form { margin: 0; } +.lan-table button { padding: .15rem .5rem; font-size: .85rem; } +.set-primary { background: var(--panel); color: var(--ink); border-color: var(--muted); } +.set-primary:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.wizard-btn { margin: .25rem 0 .5rem; } +.dns-form { margin: .75rem 0; } +.dns-form h4 { margin: .5rem 0 .35rem; } +.dns-fields { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } +.dns-fields input[name="type"] { width: 6rem; } +.dns-fields input[name="ttl"] { width: 5rem; } +.dns-fields input[name="name"], .dns-fields input[name="content"] { flex: 1 1 12rem; } + /* canvas / r-place */ .palette { display: flex; flex-wrap: wrap; gap: 4px; margin: .5rem 0; } .swatch { width: 26px; height: 26px; border: 2px solid var(--panel); border-radius: 4px; cursor: pointer; } .swatch.selected { border-color: var(--ink); } -.canvas-wrap { overflow: auto; max-width: 100%; } -#canvas { image-rendering: pixelated; cursor: crosshair; border: 2px solid var(--muted); touch-action: none; } +.canvas-viewport { + position: relative; + width: 100%; + height: 65vh; + min-height: 300px; + overflow: hidden; + background: var(--bg); + border: 2px solid var(--muted); + cursor: crosshair; + user-select: none; + touch-action: none; +} +.canvas-viewport.dragging { cursor: grabbing; } +#canvas { + position: absolute; + top: 0; left: 0; + image-rendering: pixelated; + transform-origin: 0 0; +} +.pixel-cursor { + position: absolute; + top: 0; left: 0; + pointer-events: none; + display: none; + border: 1px solid rgba(255,255,255,0.9); + box-shadow: 0 0 0 1px rgba(0,0,0,0.6); + box-sizing: border-box; +} +.canvas-coords { + position: absolute; + bottom: 6px; + left: 8px; + font-size: .75rem; + color: var(--muted); + pointer-events: none; +} footer { color: var(--muted); } diff --git a/static/js/canvas.js b/static/js/canvas.js index f164803..3c4947c 100644 --- a/static/js/canvas.js +++ b/static/js/canvas.js @@ -2,34 +2,41 @@ const cfg = window.CANVAS; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); - canvas.style.width = cfg.cols * cfg.scale + "px"; - canvas.style.height = cfg.rows * cfg.scale + "px"; + const viewport = document.getElementById("viewport"); + const coordsEl = document.getElementById("coords"); + const cursorEl = document.getElementById("pixel-cursor"); + const cdEl = document.getElementById("cooldown"); - // palette picker - let selected = 0; - const paletteEl = document.getElementById("palette"); - cfg.palette.forEach((hex, i) => { - const sw = document.createElement("div"); - sw.className = "swatch" + (i === 0 ? " selected" : ""); - sw.style.background = hex; - sw.title = hex; - sw.addEventListener("click", () => { - selected = i; - [...paletteEl.children].forEach((c) => c.classList.remove("selected")); - sw.classList.add("selected"); - }); - paletteEl.appendChild(sw); - }); + const ZOOM_MIN = 1, ZOOM_MAX = 64, DEFAULT_ZOOM = 16; + let cam = { x: 0, y: 0, zoom: DEFAULT_ZOOM }; + + function clamp() { + const vw = viewport.clientWidth, vh = viewport.clientHeight; + const w = cfg.cols * cam.zoom, h = cfg.rows * cam.zoom; + const margin = Math.min(vw, vh) * 0.5; + cam.x = Math.min(margin, Math.max(vw - w - margin, cam.x)); + cam.y = Math.min(margin, Math.max(vh - h - margin, cam.y)); + } + + function applyTransform() { + clamp(); + canvas.style.transform = `translate(${cam.x}px,${cam.y}px) scale(${cam.zoom})`; + } + + function resetView() { + const vw = viewport.clientWidth, vh = viewport.clientHeight; + cam.zoom = DEFAULT_ZOOM; + cam.x = Math.round(vw / 2 - (cfg.cols / 2) * cam.zoom); + cam.y = Math.round(vh / 2 - (cfg.rows / 2) * cam.zoom); + applyTransform(); + } + + resetView(); + window.addEventListener("resize", applyTransform); const hexFromInt = (c) => "#" + c.toString(16).padStart(6, "0"); - const drawPixel = (x, y, hex) => { - ctx.fillStyle = hex; - ctx.fillRect(x, y, 1, 1); - }; - - // The server is authoritative: we only paint pixels it broadcasts back. Open - // the socket first and queue messages until the initial state is painted, so - // nothing placed during the load is lost. + const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); }; + let loaded = false; const queue = []; function apply(msg) { @@ -41,10 +48,7 @@ function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${location.host}/canvas/ws`); - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - loaded ? apply(msg) : queue.push(msg); - }; + ws.onmessage = (e) => { const msg = JSON.parse(e.data); loaded ? apply(msg) : queue.push(msg); }; ws.onclose = () => setTimeout(connect, 1000); ws.onerror = () => ws.close(); } @@ -56,7 +60,7 @@ const bytes = new Uint8Array(buf); const img = ctx.createImageData(cfg.cols, cfg.rows); for (let p = 0; p < cfg.cols * cfg.rows; p++) { - img.data[p * 4] = bytes[p * 3]; + img.data[p * 4] = bytes[p * 3]; img.data[p * 4 + 1] = bytes[p * 3 + 1]; img.data[p * 4 + 2] = bytes[p * 3 + 2]; img.data[p * 4 + 3] = 255; @@ -67,9 +71,22 @@ queue.length = 0; }); - // cooldown indicator (server enforces; this is UI feedback) + let selected = 6; + const paletteEl = document.getElementById("palette"); + cfg.palette.forEach((hex, i) => { + const sw = document.createElement("div"); + sw.className = "swatch" + (i === selected ? " selected" : ""); + sw.style.background = hex; + sw.title = hex; + sw.addEventListener("click", () => { + selected = i; + [...paletteEl.children].forEach((c) => c.classList.remove("selected")); + sw.classList.add("selected"); + }); + paletteEl.appendChild(sw); + }); + let cooldownUntil = 0; - const cdEl = document.getElementById("cooldown"); function startCooldown(ms) { cooldownUntil = Math.max(cooldownUntil, Date.now() + ms); tick(); @@ -84,14 +101,139 @@ } } - canvas.addEventListener("click", (e) => { + function toCanvas(vx, vy) { + return { x: Math.floor((vx - cam.x) / cam.zoom), y: Math.floor((vy - cam.y) / cam.zoom) }; + } + + function inBounds(x, y) { + return x >= 0 && x < cfg.cols && y >= 0 && y < cfg.rows; + } + + function place(vx, vy) { if (Date.now() < cooldownUntil) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; - const rect = canvas.getBoundingClientRect(); - const x = Math.floor(((e.clientX - rect.left) / rect.width) * cfg.cols); - const y = Math.floor(((e.clientY - rect.top) / rect.height) * cfg.rows); - if (x < 0 || x >= cfg.cols || y < 0 || y >= cfg.rows) return; + const { x, y } = toCanvas(vx, vy); + if (!inBounds(x, y)) return; ws.send(JSON.stringify({ type: "place", x, y, i: selected })); startCooldown(cfg.cooldownMs); + } + + function updateCursor(vx, vy) { + const { x, y } = toCanvas(vx, vy); + if (inBounds(x, y) && cam.zoom >= 3) { + const px = cam.x + x * cam.zoom; + const py = cam.y + y * cam.zoom; + cursorEl.style.cssText = `display:block;width:${cam.zoom}px;height:${cam.zoom}px;transform:translate(${px}px,${py}px)`; + coordsEl.textContent = `${x}, ${y}`; + } else { + cursorEl.style.display = "none"; + coordsEl.textContent = inBounds(x, y) ? `${x}, ${y}` : ""; + } + } + + function zoomAt(vx, vy, factor) { + const z = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, cam.zoom * factor)); + cam.x = vx - (vx - cam.x) * (z / cam.zoom); + cam.y = vy - (vy - cam.y) * (z / cam.zoom); + cam.zoom = z; + applyTransform(); + } + + let drag = { on: false, sx: 0, sy: 0, cx: 0, cy: 0, moved: false }; + + viewport.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + drag = { on: true, sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false }; + viewport.classList.add("dragging"); + e.preventDefault(); + }); + + window.addEventListener("mousemove", (e) => { + if (drag.on) { + const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true; + cam.x = drag.cx + dx; + cam.y = drag.cy + dy; + applyTransform(); + } + const rect = viewport.getBoundingClientRect(); + updateCursor(e.clientX - rect.left, e.clientY - rect.top); }); + + window.addEventListener("mouseup", (e) => { + if (!drag.on) return; + if (!drag.moved) { + const rect = viewport.getBoundingClientRect(); + place(e.clientX - rect.left, e.clientY - rect.top); + } + drag.on = false; + viewport.classList.remove("dragging"); + }); + + viewport.addEventListener("mouseleave", () => { + cursorEl.style.display = "none"; + coordsEl.textContent = ""; + }); + + viewport.addEventListener("wheel", (e) => { + e.preventDefault(); + const rect = viewport.getBoundingClientRect(); + zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.15 : 1 / 1.15); + }, { passive: false }); + + let pinchDist = null; + let tapOk = false; + + function tdist(a, b) { + const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + function tmid(a, b) { + return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; + } + + viewport.addEventListener("touchstart", (e) => { + e.preventDefault(); + if (e.touches.length === 1) { + const t = e.touches[0]; + drag = { on: true, sx: t.clientX, sy: t.clientY, cx: cam.x, cy: cam.y, moved: false }; + pinchDist = null; + tapOk = true; + } else if (e.touches.length === 2) { + drag.on = false; + tapOk = false; + pinchDist = tdist(e.touches[0], e.touches[1]); + } + }, { passive: false }); + + viewport.addEventListener("touchmove", (e) => { + e.preventDefault(); + const rect = viewport.getBoundingClientRect(); + if (e.touches.length === 1 && drag.on) { + const t = e.touches[0]; + const dx = t.clientX - drag.sx, dy = t.clientY - drag.sy; + if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { drag.moved = true; tapOk = false; } + cam.x = drag.cx + dx; + cam.y = drag.cy + dy; + applyTransform(); + } else if (e.touches.length === 2 && pinchDist !== null) { + const d = tdist(e.touches[0], e.touches[1]); + const m = tmid(e.touches[0], e.touches[1]); + zoomAt(m.x - rect.left, m.y - rect.top, d / pinchDist); + pinchDist = d; + } + }, { passive: false }); + + viewport.addEventListener("touchend", (e) => { + e.preventDefault(); + if (tapOk && e.changedTouches.length === 1) { + const t = e.changedTouches[0]; + const rect = viewport.getBoundingClientRect(); + place(t.clientX - rect.left, t.clientY - rect.top); + } + drag.on = false; + tapOk = false; + if (e.touches.length < 2) pinchDist = null; + }, { passive: false }); + })(); diff --git a/static/js/dns-wizard.js b/static/js/dns-wizard.js new file mode 100644 index 0000000..2ee7162 --- /dev/null +++ b/static/js/dns-wizard.js @@ -0,0 +1,43 @@ +(function () { + const cfg = window.DNS; + if (!cfg) return; + + const form = (obj) => { + const body = new URLSearchParams(obj); + return { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }; + }; + const addRecord = (type, name, content, ttl) => + fetch("/dns", form({ type, name, content, ttl: String(ttl || 300) })); + const setPrimary = (mac) => fetch("/profile/primary", form({ mac })); + + // The wizard seeds the basics: . -> primary device, plus a starter + // alias per device you can rename or delete later. The server replaces an + // existing CNAME at a name, so re-running this is safe. + const wiz = document.getElementById("dns-wizard"); + if (wiz) { + wiz.addEventListener("click", async () => { + wiz.disabled = true; + wiz.textContent = "wiring things up…"; + const primary = cfg.devices.find((d) => d.mac === cfg.primaryMac) || cfg.devices[0]; + if (primary) await addRecord("CNAME", cfg.userDomain, primary.name, 300); + let n = 1; + for (const d of cfg.devices) { + await addRecord("CNAME", "device-" + n + "." + cfg.userDomain, d.name, 300); + n++; + } + location.reload(); + }); + } + + // Picking a primary updates the stored choice and repoints . at it + // in one go, all client-side. + document.querySelectorAll(".set-primary").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + btn.disabled = true; + await setPrimary(btn.dataset.mac); + await addRecord("CNAME", cfg.userDomain, btn.dataset.name, 300); + location.reload(); + }); + }); +})(); diff --git a/templates/base.html b/templates/base.html index 3a9f582..f2f849f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -14,8 +14,6 @@

~penguins.lan