summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
committerAlexander Hunt <me@liz.coffee>2026-06-21 12:43:31 -0700
commitcfc0d34a584254a9ed76620951c810771aff6f3b (patch)
tree7fba034f7347ce0003e7a3a56adcaf862c834907
parentfb653292612eddca5b088ed88466c546d1597107 (diff)
downloadpenguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.tar.gz
penguins.lan-cfc0d34a584254a9ed76620951c810771aff6f3b.zip
STUFF
-rw-r--r--.env.example9
-rw-r--r--README.md94
-rw-r--r--api/auth/auth.go15
-rw-r--r--api/canvas/canvas.go33
-rw-r--r--api/canvas/canvas_test.go2
-rw-r--r--api/devices/devices.go92
-rw-r--r--api/dns/dns.go147
-rw-r--r--api/serve.go30
-rw-r--r--args/args.go29
-rw-r--r--database/connections.go16
-rw-r--r--database/devices.go64
-rw-r--r--database/dns.go157
-rw-r--r--database/dns_test.go62
-rw-r--r--database/migrate.go44
-rw-r--r--database/users.go41
-rw-r--r--db/.gitkeep0
-rw-r--r--dnsd/server.go201
-rw-r--r--dnsd/server_test.go172
-rw-r--r--go.mod6
-rw-r--r--go.sum14
-rw-r--r--main.go22
-rw-r--r--mise.toml2
-rw-r--r--static/css/styles.css55
-rw-r--r--static/js/canvas.js214
-rw-r--r--static/js/dns-wizard.js43
-rw-r--r--templates/base.html2
-rw-r--r--templates/canvas.html22
-rw-r--r--templates/home.html28
-rw-r--r--templates/portal.html18
-rw-r--r--templates/profile.html74
-rw-r--r--utils/net.go26
31 files changed, 1527 insertions, 207 deletions
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
+# <mac>.<username>.<zone> 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 -> <feature continuations> -> 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 // <compact-mac>.<username>.<zone> 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 <username>.<zone> 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 <username>.<zone> 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.<user>.<zone>.").
+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 <mac>.<username> 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 "<username>.<zone>", 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
+// <username>.<zone> 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
--- /dev/null
+++ b/db/.gitkeep
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 <compact-mac>.<username>.<zone> 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 <mac>.<username> 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: <user>.zone CNAME -> <mac>.<user>.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);
- };
+ 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.
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: <user>.<zone> -> 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 <user>.<zone> 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 @@
<h1>~penguins.lan</h1>
<nav aria-label="Main">
<a href="/">home.</a>
- <span> | </span>
- <a href="/canvas">place.</a>
{{ if .User }}
<span class="spacer"></span>
<span>🐧 <a style="text-decoration: none;" class="whoami" href="/profile"><span id="me">{{ .User.Username }}</span></a></span>
diff --git a/templates/canvas.html b/templates/canvas.html
deleted file mode 100644
index 84e964d..0000000
--- a/templates/canvas.html
+++ /dev/null
@@ -1,22 +0,0 @@
-{{ define "content" }}
-<h2>~place</h2>
-<p>{{ .Canvas.Rows }}×{{ .Canvas.Cols }}, one pixel at a time. pick a color, click a cell. be nice to your fellow waddlers &lt;3</p>
-
-<div id="palette" class="palette"></div>
-
-<div class="canvas-wrap">
- <canvas id="canvas" width="{{ .Canvas.Cols }}" height="{{ .Canvas.Rows }}"></canvas>
-</div>
-<p id="cooldown" class="muted"></p>
-
-<script>
-window.CANVAS = {
- rows: {{ .Canvas.Rows }},
- cols: {{ .Canvas.Cols }},
- scale: {{ .Canvas.Scale }},
- cooldownMs: {{ .Canvas.CooldownMs }},
- palette: [{{ range $i, $c := .Canvas.Palette }}{{ if $i }}, {{ end }}"{{ $c }}"{{ end }}]
-};
-</script>
-<script src="/static/js/canvas.js"></script>
-{{ end }}
diff --git a/templates/home.html b/templates/home.html
index aeb2739..f8102e6 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -2,7 +2,7 @@
<h2>~welcome</h2>
<p>hey there penguin! or, <strong>{{ .User.Username }}</strong>, i guess.</p>
-<p>this network does not have access to the outside internet. everything runs off of this battery, phone, and mini AP; i carry it around sometimes.</p>
+<p>this network does not serve the outside internet. everything runs off of this battery, phone, and mini AP. i carry it around sometimes.</p>
<div style="text-align: center" >
<img width="60%" src="/static/img/lab.png">
@@ -33,6 +33,7 @@ git push
{{ end }}
</ul>
<script src="/static/js/waddlers.js"></script>
+<script src="/static/js/chat.js"></script>
<div id="log" class="chat-log" aria-live="polite"></div>
@@ -43,11 +44,32 @@ git push
<hr>
+<h3>~place</h3>
+<p>pick a color and click to drop it, scroll to zoom in, drag to pan the canvas. <span id="cooldown" class="muted"></span></p>
+
+<div id="palette" class="palette"></div>
+<div id="viewport" class="canvas-viewport">
+ <canvas id="canvas" width="{{ .Canvas.Cols }}" height="{{ .Canvas.Rows }}"></canvas>
+ <div id="pixel-cursor" class="pixel-cursor"></div>
+ <div id="coords" class="canvas-coords"></div>
+</div>
+
+<script>
+window.CANVAS = {
+ rows: {{ .Canvas.Rows }},
+ cols: {{ .Canvas.Cols }},
+ scale: {{ .Canvas.Scale }},
+ cooldownMs: {{ .Canvas.CooldownMs }},
+ palette: [{{ range $i, $c := .Canvas.Palette }}{{ if $i }}, {{ end }}"{{ $c }}"{{ end }}]
+};
+</script>
+<script src="/static/js/canvas.js"></script>
+
+<hr>
+
<h3>~minecraft</h3>
<p>java edition — join <code>penguins.lan</code></p>
<p id="mc-status" class="muted">checking…</p>
<script src="/static/js/minecraft.js"></script>
-
-<script src="/static/js/chat.js"></script>
{{ end }}
diff --git a/templates/portal.html b/templates/portal.html
index a2959f1..5ab5192 100644
--- a/templates/portal.html
+++ b/templates/portal.html
@@ -1,5 +1,5 @@
{{ define "content" }}
-<p>welcome to ~penguins.lan</p>
+<p>welcome to penguins.lan, penguin! by joining, you promise to be a good penguin &lt;3</p>
{{ with .Portal }}
{{ if .Taken }}
@@ -37,7 +37,7 @@
return keys.every(key => !obj[key]);
}
const send = async () => {
- await fetch(paramsObject['_authaction'] + '?tok' + paramsObject['_token'], {
+ await fetch(paramsObject['_authaction'] + '?tok=' + paramsObject['_token'], {
mode: "no-cors"
});
const data = new FormData(form);
@@ -50,17 +50,5 @@
e.preventDefault();
send().then(() => { window.location.href = "/"; });
})
-
- }
- function getQueryVariable(variable) {
- query = window.location.search.substring(1);
- vars = query.split("&");
- for (var i=0;i<vars.length;i++) {
- var pair = vars[i].split("=");
- if (pair[0] == variable) {
- return pair[1];
- }
- }
- alert('Query Variable ' + variable + ' not found');
}
-</script> \ No newline at end of file
+</script>
diff --git a/templates/profile.html b/templates/profile.html
index f0f93d9..83f97ce 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -1,8 +1,78 @@
-hellow
{{ define "content" }}
+<h2>~profile</h2>
+
<form action="/rename" method="post" class="rename-form">
<label for="username">not your name? change it:</label>
<input id="username" name="username" value="{{ .User.Username }}" maxlength="24">
<button type="submit">rename</button>
</form>
-{{ end }} \ No newline at end of file
+
+<h3>~devices</h3>
+<p>every device you've connected with is reachable at <code>&lt;mac&gt;.{{ .UserDomain }}</code>
+<br>
+the <span class="dot online"></span> one is your primary. <code>{{ .UserDomain }}</code> points there.</p>
+
+<table class="lan-table">
+ <tr><th></th><th>device.</th><th>status.</th><th>last seen.</th><th></th></tr>
+ {{ range .Devices }}
+ <tr>
+ <td>{{ if .Primary }}<span class="dot online" title="primary device"></span>{{ end }}</td>
+ <td><code>{{ .Name }}</code></td>
+ <td>{{ if .Online }}<span class="dot online"></span> {{ .IP }}{{ else }}<span class="dot offline"></span> <span class="muted">offline</span>{{ end }}</td>
+ <td class="muted">{{ .LastSeen }}</td>
+ <td>{{ if not .Primary }}<form method="post" action="/profile/primary">
+ <input type="hidden" name="mac" value="{{ .Mac }}">
+ <button class="set-primary" data-mac="{{ .Mac }}" data-name="{{ .Name }}">make primary</button>
+ </form>{{ end }}</td>
+ </tr>
+ {{ end }}
+ {{ if not .Devices }}
+ <tr><td colspan="5" class="muted">no devices seen yet — connect to the network and reload.</td></tr>
+ {{ end }}
+</table>
+
+<h3>~dns</h3>
+<p>you own <code>{{ .UserDomain }}</code>. add whatever records you want, or hit the button and let the wizard help you.</p>
+
+<button id="dns-wizard" class="wizard-btn">🧙set up my records🐧</button>
+
+<table class="lan-table">
+ <tr><th>type.</th><th>name.</th><th>content.</th><th>ttl.</th><th></th></tr>
+ {{ range .DNSRecords }}
+ <tr>
+ <td>{{ .Type }}</td>
+ <td><code>{{ .Name }}</code></td>
+ <td><code>{{ .Content }}</code></td>
+ <td>{{ .TTL }}</td>
+ <td><form method="post" action="/dns/delete">
+ <input type="hidden" name="id" value="{{ .ID }}">
+ <button>delete</button>
+ </form></td>
+ </tr>
+ {{ end }}
+ {{ if not .DNSRecords }}
+ <tr><td colspan="5" class="muted">no records yet.</td></tr>
+ {{ end }}
+</table>
+
+<form method="post" action="/dns" class="dns-form">
+ <h4>add a record</h4>
+ <div class="dns-fields">
+ <input name="type" placeholder="CNAME" maxlength="8" required>
+ <input name="name" placeholder="www (-> www.{{ .UserDomain }})" required>
+ <input name="content" placeholder="{{ .UserDomain }}" required>
+ <input name="ttl" placeholder="300" value="300">
+ <button type="submit">add</button>
+ </div>
+</form>
+
+<script>
+window.DNS = {
+ userDomain: "{{ .UserDomain }}",
+ zone: "{{ .DnsZone }}",
+ primaryMac: "{{ .PrimaryMAC }}",
+ devices: [{{ range $i, $d := .Devices }}{{ if $i }}, {{ end }}{"mac": "{{ $d.Mac }}", "name": "{{ $d.Name }}", "primary": {{ if $d.Primary }}true{{ else }}false{{ end }}}{{ end }}]
+};
+</script>
+<script src="/static/js/dns-wizard.js"></script>
+{{ end }}
diff --git a/utils/net.go b/utils/net.go
index 76454bd..c69d90f 100644
--- a/utils/net.go
+++ b/utils/net.go
@@ -16,6 +16,13 @@ func (m *Mac) Format() string {
m.Addr[0], m.Addr[1], m.Addr[2], m.Addr[3], m.Addr[4], m.Addr[5])
}
+// Compact renders the MAC as 12 lowercase hex digits with no separators, the
+// form used as a DNS label (e.g. aabbccddeeff).
+func (m *Mac) Compact() string {
+ return fmt.Sprintf("%02x%02x%02x%02x%02x%02x",
+ m.Addr[0], m.Addr[1], m.Addr[2], m.Addr[3], m.Addr[4], m.Addr[5])
+}
+
// ParseMac reads a colon-separated hex MAC. It tolerates uppercase and the
// zero-stripped octets macOS prints (e.g. "8:0:27:ab:cd:ef").
func ParseMac(s string) (*Mac, error) {
@@ -32,6 +39,25 @@ func ParseMac(s string) (*Mac, error) {
return mac, nil
}
+// ParseMacCompact reads the 12-hex-digit, separator-less form used as a DNS
+// label (e.g. aabbccddeeff) back into a Mac.
+func ParseMacCompact(s string) (*Mac, error) {
+ mac := &Mac{}
+ t := strings.TrimSpace(s)
+ if len(t) != 12 {
+ return nil, fmt.Errorf("invalid compact mac %q: want 12 hex digits, got %d", s, len(t))
+ }
+ n, err := fmt.Sscanf(t, "%2x%2x%2x%2x%2x%2x",
+ &mac.Addr[0], &mac.Addr[1], &mac.Addr[2], &mac.Addr[3], &mac.Addr[4], &mac.Addr[5])
+ if err != nil {
+ return nil, err
+ }
+ if n != 6 {
+ return nil, fmt.Errorf("invalid compact mac %q: parsed %d of 6 octets", s, n)
+ }
+ return mac, nil
+}
+
// INet4 is an IPv4 address.
type INet4 struct {
Addr [4]byte