diff options
53 files changed, 3731 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4205a95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +.git +.claude +penguins +*.db +Dockerfile +docker-compose.yml +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9764eed --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# penguins.lan config. Copy to .env (it's gitignored). +# +# DEV_MAC forces every client's MAC to this value so you can exercise the +# MAC-based login flow locally, where the browser is on loopback and never +# appears in the ARP table. NEVER set this in production β it would make every +# visitor share one identity. +# DEV_MAC=aa:bb:cc:dd:ee:ff + +# Minecraft RCON for the server status panel. Leave RCON_ADDRESS empty to disable. +# RCON_ADDRESS=127.0.0.1:25575 +# RCON_PASSWORD=changeme + +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bba11b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.DS_Store +penguins +*.db +/db/*.db +.claude diff --git a/README.md b/README.md new file mode 100644 index 0000000..70de28d --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# π§ 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/adapters/arp/arp.go b/adapters/arp/arp.go new file mode 100644 index 0000000..19d7632 --- /dev/null +++ b/adapters/arp/arp.go @@ -0,0 +1,63 @@ +package arp + +import ( + "os/exec" + "regexp" + "strings" + + "penguins.lan/utils" +) + +type ArpTable struct { + AddrToMac map[utils.INet4]utils.Mac +} + +// Lookup returns the canonical MAC for an IPv4 string, false if it isn't in the +// table or isn't parseable IPv4 (e.g. an IPv6 peer). +func (t *ArpTable) Lookup(ip string) (string, bool) { + addr, err := utils.ParseINet4(ip) + if err != nil { + return "", false + } + mac, ok := t.AddrToMac[*addr] + if !ok { + return "", false + } + return mac.Format(), true +} + +type TableProvider interface { + Fetch() (*ArpTable, error) +} + +var arpLine = regexp.MustCompile(`\((\d{1,3}(?:\.\d{1,3}){3})\) at ([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})`) + +type SystemProvider struct{} + +func (SystemProvider) Fetch() (*ArpTable, error) { + out, err := exec.Command("arp", "-an").Output() + if err != nil { + return nil, err + } + return parseTable(string(out)), nil +} + +func parseTable(out string) *ArpTable { + table := &ArpTable{AddrToMac: make(map[utils.INet4]utils.Mac)} + for _, line := range strings.Split(out, "\n") { + m := arpLine.FindStringSubmatch(line) + if m == nil { + continue + } + addr, err := utils.ParseINet4(m[1]) + if err != nil { + continue + } + mac, err := utils.ParseMac(m[2]) + if err != nil { + continue + } + table.AddrToMac[*addr] = *mac + } + return table +} diff --git a/adapters/arp/arp_test.go b/adapters/arp/arp_test.go new file mode 100644 index 0000000..9a215e0 --- /dev/null +++ b/adapters/arp/arp_test.go @@ -0,0 +1,51 @@ +package arp + +import "testing" + +const sample = `Neighbor Linklayer Address +? (192.168.1.5) at 8:0:27:ab:cd:ef on en0 ifscope [ethernet] +? (192.168.1.9) at AA:BB:CC:11:22:33 [ether] on br-lan +? (10.0.0.2) at de:ad:be:ef:00:01 on eth0 +? (192.168.1.250) at (incomplete) on en0 [ethernet] + +` + +func TestParseTable(t *testing.T) { + table := parseTable(sample) + + got := make(map[string]string, len(table.AddrToMac)) + for ip, mac := range table.AddrToMac { + got[ip.Format()] = mac.Format() + } + + want := map[string]string{ + "192.168.1.5": "08:00:27:ab:cd:ef", + "192.168.1.9": "aa:bb:cc:11:22:33", + "10.0.0.2": "de:ad:be:ef:00:01", + } + + if len(got) != len(want) { + t.Fatalf("parsed %d entries %v, want %d (header/incomplete lines should be skipped)", len(got), got, len(want)) + } + for ip, mac := range want { + if got[ip] != mac { + t.Errorf("entry %q = %q, want %q", ip, got[ip], mac) + } + } +} + +func TestTableLookup(t *testing.T) { + table := parseTable(sample) + + if mac, ok := table.Lookup("192.168.1.5"); !ok || mac != "08:00:27:ab:cd:ef" { + t.Errorf("Lookup(192.168.1.5) = %q,%v", mac, ok) + } + if _, ok := table.Lookup("1.2.3.4"); ok { + t.Error("Lookup of a miss should be false") + } + if _, ok := table.Lookup("::1"); ok { + t.Error("Lookup of non-IPv4 should be false") + } +} + +var _ TableProvider = SystemProvider{} diff --git a/adapters/netscan/netscan.go b/adapters/netscan/netscan.go new file mode 100644 index 0000000..5aaf415 --- /dev/null +++ b/adapters/netscan/netscan.go @@ -0,0 +1,74 @@ +// Package netscan nudges the kernel into ARP-resolving every host on a subnet, +// so devices that never talk to us still show up in the neighbour table. It does +// this by attempting a TCP connection to each host: the result is irrelevant, +// the ARP exchange the kernel performs to send the SYN is the point. +package netscan + +import ( + "fmt" + "net" + "sync" + "time" +) + +const ( + probePort = "9" // discard; closed almost everywhere, so a fast RST + maxHostBits = 12 // refuse to sweep anything larger than a /20 (~4094 hosts) + sweepWorkers = 32 +) + +func Sweep(cidr string, timeout time.Duration) error { + hosts, err := hostIPs(cidr) + if err != nil { + return err + } + + sem := make(chan struct{}, sweepWorkers) + var wg sync.WaitGroup + for _, ip := range hosts { + wg.Add(1) + sem <- struct{}{} + go func(ip string) { + defer wg.Done() + defer func() { <-sem }() + if conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, probePort), timeout); err == nil { + conn.Close() + } + }(ip) + } + wg.Wait() + return nil +} + +func hostIPs(cidr string) ([]string, error) { + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + if ones, bits := ipnet.Mask.Size(); bits-ones > maxHostBits { + return nil, fmt.Errorf("netscan: subnet /%d too large to sweep", ones) + } + + ip := make(net.IP, len(ipnet.IP)) + copy(ip, ipnet.IP) + + ips := []string{} + for ; ipnet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) + } + + // drop the network and broadcast addresses + if len(ips) > 2 { + ips = ips[1 : len(ips)-1] + } + return ips, nil +} + +func inc(ip net.IP) { + for i := len(ip) - 1; i >= 0; i-- { + ip[i]++ + if ip[i] != 0 { + break + } + } +} diff --git a/adapters/netscan/netscan_test.go b/adapters/netscan/netscan_test.go new file mode 100644 index 0000000..b50ef0c --- /dev/null +++ b/adapters/netscan/netscan_test.go @@ -0,0 +1,33 @@ +package netscan + +import ( + "reflect" + "testing" +) + +func TestHostIPs(t *testing.T) { + ips, err := hostIPs("192.168.1.0/30") + if err != nil { + t.Fatal(err) + } + if want := []string{"192.168.1.1", "192.168.1.2"}; !reflect.DeepEqual(ips, want) { + t.Errorf("/30 = %v, want %v", ips, want) + } + + ips, err = hostIPs("10.0.0.0/24") + if err != nil { + t.Fatal(err) + } + if len(ips) != 254 || ips[0] != "10.0.0.1" || ips[253] != "10.0.0.254" { + t.Errorf("/24 = %d hosts (%q..%q), want 254 (.1...254)", len(ips), ips[0], ips[len(ips)-1]) + } +} + +func TestHostIPsRejects(t *testing.T) { + if _, err := hostIPs("10.0.0.0/8"); err == nil { + t.Error("oversized subnet should error") + } + if _, err := hostIPs("not-a-cidr"); err == nil { + t.Error("bad cidr should error") + } +} diff --git a/adapters/rcon/rcon.go b/adapters/rcon/rcon.go new file mode 100644 index 0000000..4b9e8b6 --- /dev/null +++ b/adapters/rcon/rcon.go @@ -0,0 +1,130 @@ +// Package rcon is a minimal Source RCON client (the protocol Minecraft speaks), +// enough to authenticate and run a command like "list". +package rcon + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "time" +) + +const ( + typeResponse int32 = 0 + typeAuthResp int32 = 2 + typeExecCommand int32 = 2 + typeAuth int32 = 3 + + authRequestID int32 = 0 + execRequestID int32 = 1 + + headerSize = 8 // id (int32) + type (int32) + paddingSize = 2 // two trailing null bytes + maxBodySize = 4096 + + DefaultTimeout = 5 * time.Second +) + +var ( + ErrAuthFailed = errors.New("rcon: authentication failed") + ErrResponseMismatch = errors.New("rcon: response id mismatch") + ErrPacketSize = errors.New("rcon: invalid packet size") +) + +type Client struct { + conn net.Conn + timeout time.Duration +} + +func Dial(address, password string, timeout time.Duration) (*Client, error) { + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return nil, err + } + + client := &Client{conn: conn, timeout: timeout} + if err := client.auth(password); err != nil { + conn.Close() + return nil, err + } + return client, nil +} + +func (c *Client) Close() error { return c.conn.Close() } + +func (c *Client) auth(password string) error { + if err := c.write(authRequestID, typeAuth, password); err != nil { + return err + } + + // Some servers send an empty SERVERDATA_RESPONSE_VALUE before the auth + // response; skip anything that isn't the auth response. + for { + id, typ, _, err := c.read() + if err != nil { + return err + } + if typ != typeAuthResp { + continue + } + if id == -1 { + return ErrAuthFailed + } + return nil + } +} + +func (c *Client) Execute(command string) (string, error) { + if err := c.write(execRequestID, typeExecCommand, command); err != nil { + return "", err + } + + id, _, body, err := c.read() + if err != nil { + return "", err + } + if id != execRequestID { + return "", ErrResponseMismatch + } + return body, nil +} + +func (c *Client) write(id, typ int32, body string) error { + c.conn.SetWriteDeadline(time.Now().Add(c.timeout)) + + var buf bytes.Buffer + size := int32(headerSize + len(body) + paddingSize) + binary.Write(&buf, binary.LittleEndian, size) + binary.Write(&buf, binary.LittleEndian, id) + binary.Write(&buf, binary.LittleEndian, typ) + buf.WriteString(body) + buf.Write([]byte{0, 0}) + + _, err := c.conn.Write(buf.Bytes()) + return err +} + +func (c *Client) read() (id, typ int32, body string, err error) { + c.conn.SetReadDeadline(time.Now().Add(c.timeout)) + + var size int32 + if err = binary.Read(c.conn, binary.LittleEndian, &size); err != nil { + return + } + if size < headerSize+paddingSize || size > headerSize+maxBodySize+paddingSize { + err = ErrPacketSize + return + } + + payload := make([]byte, size) + if _, err = io.ReadFull(c.conn, payload); err != nil { + return + } + + id = int32(binary.LittleEndian.Uint32(payload[0:4])) + typ = int32(binary.LittleEndian.Uint32(payload[4:8])) + body = string(payload[headerSize : size-paddingSize]) + return +} diff --git a/adapters/rcon/rcon_test.go b/adapters/rcon/rcon_test.go new file mode 100644 index 0000000..f712fa5 --- /dev/null +++ b/adapters/rcon/rcon_test.go @@ -0,0 +1,91 @@ +package rcon + +import ( + "encoding/binary" + "errors" + "io" + "net" + "testing" + "time" +) + +func writeTestPacket(w io.Writer, id, typ int32, body string) { + binary.Write(w, binary.LittleEndian, int32(headerSize+len(body)+paddingSize)) + binary.Write(w, binary.LittleEndian, id) + binary.Write(w, binary.LittleEndian, typ) + io.WriteString(w, body) + w.Write([]byte{0, 0}) +} + +func readTestPacket(r io.Reader) (id, typ int32, body string) { + var size int32 + binary.Read(r, binary.LittleEndian, &size) + payload := make([]byte, size) + io.ReadFull(r, payload) + id = int32(binary.LittleEndian.Uint32(payload[0:4])) + typ = int32(binary.LittleEndian.Uint32(payload[4:8])) + return id, typ, string(payload[headerSize : size-paddingSize]) +} + +// fakeServer speaks just enough Source RCON to exercise the client. +func fakeServer(t *testing.T, password string) string { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + + id, typ, body := readTestPacket(conn) + if typ != typeAuth { + return + } + respID := id + if body != password { + respID = -1 + } + writeTestPacket(conn, id, typeResponse, "") // empty value first + writeTestPacket(conn, respID, typeAuthResp, "") + if respID == -1 { + return + } + + cid, _, cbody := readTestPacket(conn) + out := "unknown command" + if cbody == "list" { + out = "There are 1 of a max of 20 players online: Steve" + } + writeTestPacket(conn, cid, typeResponse, out) + }() + + return ln.Addr().String() +} + +func TestClientExecute(t *testing.T) { + client, err := Dial(fakeServer(t, "secret"), "secret", time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + out, err := client.Execute("list") + if err != nil { + t.Fatal(err) + } + if want := "There are 1 of a max of 20 players online: Steve"; out != want { + t.Errorf("Execute = %q, want %q", out, want) + } +} + +func TestClientAuthFailure(t *testing.T) { + _, err := Dial(fakeServer(t, "secret"), "wrong", time.Second) + if !errors.Is(err, ErrAuthFailed) { + t.Errorf("Dial err = %v, want ErrAuthFailed", err) + } +} diff --git a/api/auth/auth.go b/api/auth/auth.go new file mode 100644 index 0000000..a70af32 --- /dev/null +++ b/api/auth/auth.go @@ -0,0 +1,280 @@ +package auth + +import ( + "errors" + "log" + "net" + "net/http" + "time" + + "penguins.lan/adapters/arp" + "penguins.lan/api/types" + "penguins.lan/database" + "penguins.lan/utils" +) + +const ( + sessionCookie = "penguin_session" + sessionTTL = 30 * 24 * time.Hour +) + +func ClientIP(req *http.Request) string { + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + return host +} + +// clientMac resolves the visitor's MAC from the DEV_MAC override or the ARP +// cache the scheduler keeps fresh. "" means we don't know it. +func clientMac(context *types.RequestContext, req *http.Request) string { + if dev := context.Args.DevMAC; dev != "" { + if mac, err := utils.ParseMac(dev); err == nil { + return mac.Format() + } + return "" + } + ip := ClientIP(req) + mac, err := database.MacForIP(context.DBConn, ip) + if err != nil { + log.Println("mac lookup failed:", err) + } + if mac != "" { + return mac + } + return liveMac(ip) +} + +// liveMac falls back to a direct ARP read for a brand-new connection the +// scheduler hasn't cached yet. Non-IPv4 peers (e.g. loopback) are skipped so we +// don't shell out for addresses that can never resolve. +func liveMac(ip string) string { + if _, err := utils.ParseINet4(ip); err != nil { + return "" + } + table, err := arp.SystemProvider{}.Fetch() + if err != nil { + log.Println("live arp lookup failed:", err) + return "" + } + mac, _ := table.Lookup(ip) + return mac +} + +func setSessionCookie(resp http.ResponseWriter, id string) { + http.SetCookie(resp, &http.Cookie{ + Name: sessionCookie, + Value: id, + Path: "/", + MaxAge: int(sessionTTL.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearSessionCookie(resp http.ResponseWriter) { + http.SetCookie(resp, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) +} + +func LoginUser(context *types.RequestContext, resp http.ResponseWriter, user *database.User) error { + session, err := database.CreateSession(context.DBConn, utils.RandomId(), user.ID, sessionTTL) + if err != nil { + return err + } + setSessionCookie(resp, session.ID) + context.User = user + context.Nick = user.Username + return nil +} + +func ResolveSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" { + if user, err := database.GetSessionUser(context.DBConn, cookie.Value); err == nil { + context.User = user + context.Nick = user.Username + _ = database.TouchUser(context.DBConn, user.ID) + return success(context, req, resp) + } + } + + mac := clientMac(context, req) + context.ClientMAC = mac + + if mac != "" { + if user, err := database.GetUserByMAC(context.DBConn, mac); err == nil { + if err := LoginUser(context, resp, user); err != nil { + log.Println("mac auto-login failed:", err) + } else { + _ = database.TouchUser(context.DBConn, user.ID) + } + } + } + + return success(context, req, resp) + } +} + +func RenameContinuation(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 success(context, req, resp) + } + + newName := utils.SanitizeName(req.FormValue("username")) + if newName == "" || newName == context.User.Username { + http.Redirect(resp, req, "/", http.StatusSeeOther) + return success(context, req, resp) + } + + renameError := func(message string) types.ContinuationChain { + (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}} + return failure(context, req, resp) + } + + switch existing, err := database.GetUserByUsername(context.DBConn, newName); { + case err == nil && existing.ID != context.User.ID: + return renameError("that handle's taken.") + case err != nil && !errors.Is(err, database.ErrNotFound): + log.Println("rename lookup failed:", err) + return renameError("something broke, try again") + } + + if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil { + log.Println("rename failed:", err) + return renameError("couldn't rename, try again") + } + + context.User.Username = newName + context.Nick = newName + http.Redirect(resp, req, "/", http.StatusSeeOther) + return success(context, req, resp) + } +} + +func RequireUserContinuation(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 success(context, req, resp) + } + return failure(context, req, resp) + } +} + +func GoPortalContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + http.Redirect(resp, req, "/portal", http.StatusSeeOther) + return success(context, req, resp) + } +} + +func UnauthorizedContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + resp.WriteHeader(http.StatusUnauthorized) + return success(context, req, resp) + } +} + +func LogoutContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" { + _ = database.DeleteSession(context.DBConn, cookie.Value) + } + clearSessionCookie(resp) + http.Redirect(resp, req, "/portal", http.StatusSeeOther) + return success(context, req, resp) + } +} + +type portalView struct { + SuggestedName string + RequestedName string + DetectedMAC string + Taken bool + LastSeen string +} + +func ShowPortalContinuation(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 { + http.Redirect(resp, req, "/", http.StatusSeeOther) + return failure(context, req, resp) + } + (*context.TemplateData)["Portal"] = portalView{ + SuggestedName: utils.SuggestName(), + DetectedMAC: context.ClientMAC, + } + return success(context, req, resp) + } +} + +func SetupUserContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + username := utils.SanitizeName(req.FormValue("username")) + reclaim := req.FormValue("reclaim") == "1" + + mac := clientMac(context, req) + context.ClientMAC = mac + + view := portalView{ + SuggestedName: utils.SuggestName(), + RequestedName: username, + DetectedMAC: mac, + } + + fail := func(message string) types.ContinuationChain { + if message != "" { + (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}} + } + (*context.TemplateData)["Portal"] = view + return failure(context, req, resp) + } + + if username == "" { + resp.WriteHeader(http.StatusBadRequest) + return fail("pick a handle, any handle π§") + } + + existing, err := database.GetUserByUsername(context.DBConn, username) + switch { + case errors.Is(err, database.ErrNotFound): + user, err := database.CreateUser(context.DBConn, utils.RandomId(), username) + if err != nil { + log.Println("create user failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return fail("couldn't create your account, try again") + } + bindAndFinish(context, resp, user, mac) + http.Redirect(resp, req, "/", http.StatusSeeOther) + return success(context, req, resp) + + case err != nil: + log.Println("lookup user failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return fail("something broke, try again") + + default: + if !reclaim { + view.Taken = true + view.LastSeen = utils.FormatSince(existing.LastSeen) + return fail("") + } + bindAndFinish(context, resp, existing, mac) + http.Redirect(resp, req, "/", http.StatusSeeOther) + return success(context, req, resp) + } + } +} + +func bindAndFinish(context *types.RequestContext, resp http.ResponseWriter, user *database.User, mac string) { + if mac != "" { + if err := database.AssociateMAC(context.DBConn, mac, user.ID); err != nil { + log.Println("associate mac failed:", err) + } + } + _ = database.TouchUser(context.DBConn, user.ID) + if err := LoginUser(context, resp, user); err != nil { + log.Println("login failed:", err) + } +} diff --git a/api/canvas/canvas.go b/api/canvas/canvas.go new file mode 100644 index 0000000..717d924 --- /dev/null +++ b/api/canvas/canvas.go @@ -0,0 +1,373 @@ +// 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 ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" + + "penguins.lan/api/types" + "penguins.lan/database" +) + +const ( + Rows = 64 + Cols = 64 + Scale = 8 + + placeCooldown = 2 * time.Second + snapshotThreshold = 50 + defaultColor = 0xffffff + + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 256 +) + +// r/place 2017 palette. +var palette = []int{ + 0xffffff, 0xe4e4e4, 0x888888, 0x222222, + 0xffa7d1, 0xe50000, 0xe59500, 0xa06a42, + 0xe5d900, 0x94e044, 0x02be01, 0x00d3dd, + 0x0083c7, 0x0000ea, 0xcf6ee4, 0x820080, +} + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type inbound struct { + Type string `json:"type"` + X int `json:"x"` + Y int `json:"y"` + I int `json:"i"` +} + +type outbound struct { + Type string `json:"type"` + X int `json:"x"` + Y int `json:"y"` + C int `json:"c"` + Ms int64 `json:"ms,omitempty"` +} + +// ---- snapshot / state ------------------------------------------------------ + +func blankCanvas() []byte { + buf := make([]byte, Rows*Cols*3) + for i := 0; i < len(buf); i += 3 { + buf[i] = byte(defaultColor >> 16) + buf[i+1] = byte((defaultColor >> 8) & 0xff) + buf[i+2] = byte(defaultColor & 0xff) + } + return buf +} + +func setPixel(buf []byte, x, y, color int) { + if x < 0 || x >= Cols || y < 0 || y >= Rows { + return + } + i := (y*Cols + x) * 3 + buf[i] = byte(color >> 16) + buf[i+1] = byte(color >> 8) + 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 { + return nil, 0, err + } + if snap != nil && len(snap.Data) == Rows*Cols*3 { + buf := make([]byte, len(snap.Data)) + copy(buf, snap.Data) + return buf, snap.ThroughID, nil + } + return blankCanvas(), 0, nil +} + +func currentCanvas(db *sql.DB) ([]byte, error) { + buf, through, err := baseline(db) + if err != nil { + return nil, err + } + pixels, err := database.PixelsAfter(db, through) + if err != nil { + return nil, err + } + for _, p := range pixels { + setPixel(buf, p.X, p.Y, p.Color) + } + return buf, nil +} + +func takeSnapshot(db *sql.DB) error { + buf, through, err := baseline(db) + if err != nil { + return err + } + pixels, err := database.PixelsAfter(db, through) + if err != nil { + return err + } + if len(pixels) == 0 { + return nil + } + + maxID := through + for _, p := range pixels { + setPixel(buf, p.X, p.Y, p.Color) + if p.ID > maxID { + maxID = p.ID + } + } + if err := database.SaveCanvasSnapshot(db, buf, maxID); err != nil { + return err + } + return database.DeleteOldCanvasSnapshots(db) +} + +// ---- hub ------------------------------------------------------------------- + +type Hub struct { + db *sql.DB + clients map[*Client]bool + register chan *Client + unregister chan *Client + broadcast chan []byte + place chan placement + lastPlaced map[string]time.Time + sinceSnapshot int +} + +type placement struct { + client *Client + x, y, i int +} + +func NewHub(db *sql.DB) *Hub { + return &Hub{ + db: db, + clients: make(map[*Client]bool), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan []byte), + place: make(chan placement), + lastPlaced: make(map[string]time.Time), + } +} + +func (h *Hub) Run() { + if _, through, err := baseline(h.db); err == nil { + if n, err := database.CountPixelsAfter(h.db, through); err == nil { + h.sinceSnapshot = n + } + } + + for { + select { + case client := <-h.register: + h.clients[client] = true + case client := <-h.unregister: + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + case payload := <-h.broadcast: + h.deliver(payload) + case p := <-h.place: + h.handlePlace(p) + } + } +} + +func (h *Hub) deliver(payload []byte) { + for client := range h.clients { + select { + case client.send <- payload: + default: + delete(h.clients, client) + close(client.send) + } + } +} + +func (h *Hub) sendTo(client *Client, msg outbound) { + if payload, err := json.Marshal(msg); err == nil { + select { + case client.send <- payload: + default: + } + } +} + +func (h *Hub) handlePlace(p placement) { + if p.x < 0 || p.x >= Cols || p.y < 0 || p.y >= Rows || p.i < 0 || p.i >= len(palette) { + return + } + + now := time.Now() + if last, ok := h.lastPlaced[p.client.userID]; ok { + if remaining := placeCooldown - now.Sub(last); remaining > 0 { + h.sendTo(p.client, outbound{Type: "cooldown", Ms: remaining.Milliseconds()}) + return + } + } + + color := palette[p.i] + if _, err := database.SavePixel(h.db, p.client.userID, p.x, p.y, color); err != nil { + log.Println("canvas: save pixel failed:", err) + return + } + h.lastPlaced[p.client.userID] = now + + if payload, err := json.Marshal(outbound{Type: "place", X: p.x, Y: p.y, C: color}); err == nil { + h.deliver(payload) + } + + h.sinceSnapshot++ + if h.sinceSnapshot >= snapshotThreshold { + if err := takeSnapshot(h.db); err != nil { + log.Println("canvas: snapshot failed:", err) + } else { + h.sinceSnapshot = 0 + } + } +} + +// ---- client ---------------------------------------------------------------- + +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + userID string +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + break + } + var in inbound + if json.Unmarshal(raw, &in) != nil || in.Type != "place" { + continue + } + c.hub.place <- placement{client: c, x: in.X, y: in.Y, i: in.I} + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case payload, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// ---- continuations --------------------------------------------------------- + +type pageConfig struct { + Rows int + Cols int + Scale int + CooldownMs int64 + Palette []string +} + +func paletteHex() []string { + out := make([]string, len(palette)) + for i, c := range palette { + out[i] = fmt.Sprintf("#%06x", c) + } + return out +} + +func PageContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + (*context.TemplateData)["Canvas"] = pageConfig{ + Rows: Rows, + Cols: Cols, + Scale: Scale, + CooldownMs: placeCooldown.Milliseconds(), + Palette: paletteHex(), + } + return success(context, req, resp) + } +} + +func StateContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + buf, err := currentCanvas(context.DBConn) + if err != nil { + log.Println("canvas: state failed:", err) + resp.WriteHeader(http.StatusInternalServerError) + return success(context, req, resp) + } + resp.Header().Set("Content-Type", "application/octet-stream") + resp.Write(buf) + return success(context, req, resp) + } +} + +func ServeWSContinuation(hub *Hub) types.Continuation { + return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + conn, err := upgrader.Upgrade(resp, req, nil) + if err != nil { + log.Println("canvas: upgrade failed", err) + return failure(context, req, resp) + } + + client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256), userID: context.User.ID} + hub.register <- client + go client.writePump() + go client.readPump() + + return success(context, req, resp) + } + } +} diff --git a/api/canvas/canvas_test.go b/api/canvas/canvas_test.go new file mode 100644 index 0000000..324a3a1 --- /dev/null +++ b/api/canvas/canvas_test.go @@ -0,0 +1,83 @@ +package canvas + +import ( + "database/sql" + "path/filepath" + "testing" + + "penguins.lan/database" +) + +func canvasTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "c.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 pixelAt(buf []byte, x, y int) int { + i := (y*Cols + x) * 3 + return int(buf[i])<<16 | int(buf[i+1])<<8 | int(buf[i+2]) +} + +func TestCurrentCanvasReplaysPixels(t *testing.T) { + db := canvasTestDB(t) + + if _, err := database.SavePixel(db, "u", 1, 2, 0xe50000); err != nil { + t.Fatal(err) + } + + buf, err := currentCanvas(db) + if err != nil { + t.Fatal(err) + } + if got := pixelAt(buf, 1, 2); got != 0xe50000 { + t.Errorf("placed pixel = %06x, want e50000", got) + } + if got := pixelAt(buf, 0, 0); got != defaultColor { + t.Errorf("untouched pixel = %06x, want white", got) + } +} + +func TestSnapshotBakesThenReplays(t *testing.T) { + db := canvasTestDB(t) + + if _, err := database.SavePixel(db, "u", 5, 5, 0x000000); err != nil { + t.Fatal(err) + } + 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) + } + + buf, err := currentCanvas(db) + if err != nil { + t.Fatal(err) + } + if got := pixelAt(buf, 5, 5); got != 0x000000 { + t.Errorf("baked pixel = %06x, want black", got) + } + if got := pixelAt(buf, 6, 6); got != 0x02be01 { + t.Errorf("replayed pixel = %06x, want green", got) + } + + // snapshots are kept to one row + if err := takeSnapshot(db); err != nil { + t.Fatal(err) + } + var n int + if err := db.QueryRow("SELECT COUNT(*) FROM canvas_snapshots").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("snapshot rows = %d, want 1", n) + } +} diff --git a/api/minecraft/minecraft.go b/api/minecraft/minecraft.go new file mode 100644 index 0000000..16cc0f3 --- /dev/null +++ b/api/minecraft/minecraft.go @@ -0,0 +1,82 @@ +package minecraft + +import ( + "encoding/json" + "io" + "log" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "penguins.lan/adapters/rcon" + "penguins.lan/api/types" + "penguins.lan/database" +) + +const ( + queryTimeout = 3 * time.Second + // staleAfter marks the cache offline if the scheduler stopped refreshing it. + staleAfter = 30 * time.Second +) + +type Status struct { + Online bool `json:"online"` + Count int `json:"count"` + Max int `json:"max"` + Players []string `json:"players"` +} + +var listPattern = regexp.MustCompile(`There are (\d+) of a max of (\d+) players online:?\s*(.*)`) + +// Query runs "list" over RCON. The scheduler calls this; requests read the cache. +func Query(address, password string) (Status, error) { + client, err := rcon.Dial(address, password, queryTimeout) + if err != nil { + return Status{}, err + } + defer client.Close() + + out, err := client.Execute("list") + if err != nil { + return Status{}, err + } + return parseList(out), nil +} + +func parseList(out string) Status { + m := listPattern.FindStringSubmatch(strings.TrimSpace(out)) + if m == nil { + return Status{Online: true} + } + + count, _ := strconv.Atoi(m[1]) + max, _ := strconv.Atoi(m[2]) + + players := []string{} + for _, p := range strings.Split(m[3], ",") { + if p = strings.TrimSpace(p); p != "" { + players = append(players, p) + } + } + return Status{Online: true, Count: count, Max: max, Players: players} +} + +func StatusContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + resp.Header().Set("Content-Type", "application/json") + + cached, updatedAt, err := database.GetMinecraftStatus(context.DBConn) + if err != nil { + log.Println("minecraft: read cache failed:", err) + } + + if cached == "" || time.Since(updatedAt) > staleAfter { + json.NewEncoder(resp).Encode(Status{Online: false}) + } else { + io.WriteString(resp, cached) + } + return success(context, req, resp) + } +} diff --git a/api/minecraft/minecraft_test.go b/api/minecraft/minecraft_test.go new file mode 100644 index 0000000..0c89fc5 --- /dev/null +++ b/api/minecraft/minecraft_test.go @@ -0,0 +1,40 @@ +package minecraft + +import ( + "reflect" + "testing" +) + +func TestParseList(t *testing.T) { + cases := []struct { + out string + want Status + }{ + { + "There are 2 of a max of 20 players online: Alice, Bob", + Status{Online: true, Count: 2, Max: 20, Players: []string{"Alice", "Bob"}}, + }, + { + "There are 0 of a max of 20 players online:", + Status{Online: true, Count: 0, Max: 20, Players: []string{}}, + }, + { + "There are 1 of a max of 20 players online: Steve\n", + Status{Online: true, Count: 1, Max: 20, Players: []string{"Steve"}}, + }, + { + "some unexpected response", + Status{Online: true, Count: 0, Max: 0, Players: []string{}}, + }, + } + + for _, c := range cases { + got := parseList(c.out) + if got.Players == nil { + got.Players = []string{} + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("parseList(%q) = %+v, want %+v", c.out, got, c.want) + } + } +} diff --git a/api/presence/presence.go b/api/presence/presence.go new file mode 100644 index 0000000..645b34e --- /dev/null +++ b/api/presence/presence.go @@ -0,0 +1,64 @@ +package presence + +import ( + "encoding/json" + "log" + "net/http" + + "penguins.lan/api/types" + "penguins.lan/api/ws" + "penguins.lan/database" + "penguins.lan/utils" +) + +type Waddler struct { + Username string `json:"username"` + Status string `json:"status"` + LastSeen string `json:"lastSeen"` +} + +func waddlers(context *types.RequestContext, hub *ws.Hub) []Waddler { + users, err := database.ListUsers(context.DBConn) + if err != nil { + log.Println("presence: list users failed:", err) + } + onChat := hub.Online() + onNetwork, err := database.NetworkUserIDs(context.DBConn) + if err != nil { + log.Println("presence: network ids failed:", err) + } + + list := make([]Waddler, 0, len(users)) + for _, u := range users { + status := "offline" + if onNetwork[u.ID] { + status = "network" + } + if onChat[u.ID] { + status = "chat" + } + list = append(list, Waddler{u.Username, status, utils.FormatSince(u.LastSeen)}) + } + return list +} + +func RosterContinuation(hub *ws.Hub) types.Continuation { + return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + (*context.TemplateData)["Users"] = waddlers(context, hub) + return success(context, req, resp) + } + } +} + +func StatusContinuation(hub *ws.Hub) types.Continuation { + return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + resp.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(resp).Encode(waddlers(context, hub)); err != nil { + log.Println("presence: encode failed:", err) + } + return success(context, req, resp) + } + } +} diff --git a/api/serve.go b/api/serve.go new file mode 100644 index 0000000..0005369 --- /dev/null +++ b/api/serve.go @@ -0,0 +1,165 @@ +package api + +import ( + "database/sql" + "fmt" + "log" + "net/http" + "time" + + "penguins.lan/api/auth" + "penguins.lan/api/canvas" + "penguins.lan/api/minecraft" + "penguins.lan/api/presence" + "penguins.lan/api/template" + "penguins.lan/api/types" + "penguins.lan/api/ws" + "penguins.lan/args" + "penguins.lan/utils" +) + +func LogRequestContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + context.Start = time.Now() + context.Id = utils.RandomId() + + log.Println(req.Method, req.URL.Path, req.RemoteAddr, context.Id) + return success(context, req, resp) + } +} + +func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + log.Println(context.Id, "took", time.Since(context.Start)) + return success(context, req, resp) + } +} + +func HealthCheckContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + resp.WriteHeader(http.StatusOK) + resp.Write([]byte("healthy")) + return success(context, req, resp) + } +} + +func FailurePassingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(_success types.Continuation, failure types.Continuation) types.ContinuationChain { + return failure(context, req, resp) + } +} + +func IdContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { + return success(context, req, resp) + } +} + +func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) + next.ServeHTTP(w, r) + }) +} + +func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { + mux := http.NewServeMux() + + hub := ws.NewHub(dbConn) + go hub.Run() + + canvasHub := canvas.NewHub(dbConn) + go canvasHub.Run() + + makeRequestContext := func() *types.RequestContext { + return &types.RequestContext{ + DBConn: dbConn, + Args: argv, + TemplateData: &map[string]interface{}{}, + } + } + + staticFileServer := http.FileServer(http.Dir(argv.StaticPath)) + mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600))) + + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(HealthCheckContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /portal", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.ShowPortalContinuation, FailurePassingContinuation)(template.TemplateContinuation("portal.html", true), IdContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /portal", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.SetupUserContinuation, FailurePassingContinuation)(IdContinuation, template.TemplateContinuation("portal.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + 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) + }) + + mux.HandleFunc("GET /logout", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.LogoutContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + 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) + }) + + mux.HandleFunc("POST /rename", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(presence.RosterContinuation(hub), auth.GoPortalContinuation)(auth.RenameContinuation, auth.GoPortalContinuation)(IdContinuation, template.TemplateContinuation("home.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /waddlers", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + 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) + }) + + mux.HandleFunc("GET /canvas/ws", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(canvas.ServeWSContinuation(canvasHub), auth.UnauthorizedContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /minecraft", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(minecraft.StatusContinuation, auth.UnauthorizedContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /chat", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation("chat.html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(ws.ServeWSContinuation(hub), auth.UnauthorizedContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /{template}", func(w http.ResponseWriter, r *http.Request) { + ctx := makeRequestContext() + templateFile := r.PathValue("template") + LogRequestContinuation(ctx, r, w)(auth.ResolveSessionContinuation, FailurePassingContinuation)(auth.RequireUserContinuation, FailurePassingContinuation)(template.TemplateContinuation(templateFile+".html", true), auth.GoPortalContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + return &http.Server{ + Addr: ":" + fmt.Sprint(argv.Port), + Handler: mux, + } +} diff --git a/api/template/template.go b/api/template/template.go new file mode 100644 index 0000000..f1f4acd --- /dev/null +++ b/api/template/template.go @@ -0,0 +1,77 @@ +package template + +import ( + "bytes" + "errors" + "html/template" + "log" + "net/http" + "os" + + "penguins.lan/api/types" +) + +func renderTemplate(context *types.RequestContext, templateName string, showBaseHtml bool) (bytes.Buffer, error) { + templatePath := context.Args.TemplatePath + basePath := templatePath + "/base_empty.html" + if showBaseHtml { + basePath = templatePath + "/base.html" + } + + templateLocation := templatePath + "/" + templateName + tmpl, err := template.New("").ParseFiles(templateLocation, basePath) + if err != nil { + return bytes.Buffer{}, err + } + + dataPtr := context.TemplateData + if dataPtr == nil { + dataPtr = &map[string]interface{}{} + } + + data := *dataPtr + if data["Nick"] == nil { + data["Nick"] = context.Nick + } + if data["User"] == nil { + data["User"] = context.User + } + + var buffer bytes.Buffer + if err := tmpl.ExecuteTemplate(&buffer, "base", data); err != nil { + return bytes.Buffer{}, err + } + return buffer, nil +} + +func TemplateContinuation(path string, showBase bool) types.Continuation { + return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + html, err := renderTemplate(context, path, showBase) + if errors.Is(err, os.ErrNotExist) { + resp.WriteHeader(http.StatusNotFound) + html, err = renderTemplate(context, "404.html", true) + if err != nil { + log.Println("error rendering 404 template", err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + resp.Header().Set("Content-Type", "text/html") + resp.Write(html.Bytes()) + return failure(context, req, resp) + } + + if err != nil { + log.Println("error rendering template", err) + resp.WriteHeader(http.StatusInternalServerError) + resp.Write([]byte("error rendering template")) + return failure(context, req, resp) + } + + resp.Header().Set("Content-Type", "text/html") + resp.Write(html.Bytes()) + return success(context, req, resp) + } + } +} diff --git a/api/types/types.go b/api/types/types.go new file mode 100644 index 0000000..dc096d8 --- /dev/null +++ b/api/types/types.go @@ -0,0 +1,37 @@ +package types + +import ( + "database/sql" + "net/http" + "time" + + "penguins.lan/args" + "penguins.lan/database" +) + +type RequestContext struct { + DBConn *sql.DB + Args *args.Arguments + + Id string + Start time.Time + + // User is the authenticated account (nil until resolved/required). + User *database.User + // ClientMAC is the visitor's hardware address, if we could read it. + ClientMAC string + // Nick mirrors User.Username for templates and the chat hub. + Nick string + + TemplateData *map[string]interface{} +} + +type BannerMessages struct { + Messages []string +} + +// Continuation-passing style: each step is a Continuation that, given a +// (success, failure) pair, returns the next link in the chain. This lets routes +// read as a flat pipeline in serve.go while still branching on errors. +type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain +type ContinuationChain func(Continuation, Continuation) ContinuationChain diff --git a/api/ws/ws.go b/api/ws/ws.go new file mode 100644 index 0000000..63706db --- /dev/null +++ b/api/ws/ws.go @@ -0,0 +1,293 @@ +package ws + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" + + "penguins.lan/api/types" + "penguins.lan/database" + "penguins.lan/utils" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 4096 + + historyLimit = 50 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // It's a LAN intranet; we don't police origins. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Message is the wire format shared by client and server. `Type` discriminates +// the payload so we can grow beyond chat without breaking old clients. +type Message struct { + Type string `json:"type"` // "chat" | "system" | "history" + Nick string `json:"nick,omitempty"` + Body string `json:"body,omitempty"` + At int64 `json:"at,omitempty"` // unix millis + // History carries a batch of past messages on join. + History []Message `json:"history,omitempty"` +} + +// Hub fans messages out to every connected client. +type Hub struct { + db *sql.DB + clients map[*Client]bool + broadcast chan []byte + register chan *Client + unregister chan *Client + online chan chan map[string]bool +} + +func NewHub(db *sql.DB) *Hub { + return &Hub{ + db: db, + clients: make(map[*Client]bool), + broadcast: make(chan []byte), + register: make(chan *Client), + unregister: make(chan *Client), + online: make(chan chan map[string]bool), + } +} + +// Run owns all mutations to the client set, so no locks are needed elsewhere. +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.clients[client] = true + h.systemf("%s waddled in π§", h.displayName(client.userID)) + case client := <-h.unregister: + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + h.systemf("%s slid away", h.displayName(client.userID)) + } + case payload := <-h.broadcast: + h.deliver(payload) + case reply := <-h.online: + set := make(map[string]bool, len(h.clients)) + for client := range h.clients { + set[client.userID] = true + } + reply <- set + } + } +} + +// Online returns the set of userIDs currently connected to the chat. +func (h *Hub) Online() map[string]bool { + reply := make(chan map[string]bool) + h.online <- reply + return <-reply +} + +// displayName resolves a userID to its current username. The chat hub never +// caches names, so a rename is reflected everywhere immediately. +func (h *Hub) displayName(userID string) string { + user, err := database.GetUserByID(h.db, userID) + if err != nil { + log.Println("ws: could not resolve user", userID, err) + return "a mystery penguin" + } + return user.Username +} + +// deliver fans a payload out to every client, dropping any that can't keep up. +// Only safe to call from the Run goroutine, which owns the clients map. +func (h *Hub) deliver(payload []byte) { + for client := range h.clients { + select { + case client.send <- payload: + default: + // slow consumer: drop them rather than block the hub + delete(h.clients, client) + close(client.send) + } + } +} + +// sendJSON queues a message for broadcast from outside the hub goroutine +// (e.g. a chat message arriving on a client's readPump). +func (h *Hub) sendJSON(msg Message) { + payload, err := json.Marshal(msg) + if err != nil { + log.Println("ws: marshal error", err) + return + } + h.broadcast <- payload +} + +// systemf emits a system notice. It's only ever called from within Run, so it +// fans out directly β re-entering the broadcast channel here would deadlock the +// hub (the loop can't both send and receive on it at once). +func (h *Hub) systemf(format string, args ...interface{}) { + payload, err := json.Marshal(Message{ + Type: "system", + Body: fmt.Sprintf(format, args...), + At: time.Now().UnixMilli(), + }) + if err != nil { + log.Println("ws: marshal error", err) + return + } + h.deliver(payload) +} + +// Client is one websocket connection. Reads happen on readPump, writes on +// writePump, communicating through the buffered `send` channel. Identity is the +// userID alone β the display name is always resolved from it, never cached. +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + userID string +} + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Println("ws: read error", err) + } + break + } + + var incoming Message + if err := json.Unmarshal(raw, &incoming); err != nil { + continue + } + + // The server is authoritative about who you are and when it happened. + body := utils.Sanitize(incoming.Body) + if incoming.Type != "chat" || body == "" { + continue + } + + stored := &database.ChatMessage{ + ID: utils.RandomId(), + UserID: c.userID, + Body: utils.Truncate(incoming.Body, 500), + } + if _, err := database.SaveChatMessage(c.hub.db, stored); err != nil { + log.Println("ws: failed to persist chat", err) + } + + c.hub.sendJSON(Message{ + Type: "chat", + Nick: c.hub.displayName(c.userID), + Body: stored.Body, + At: time.Now().UnixMilli(), + }) + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case payload, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// sendHistory replays recent messages to a freshly-connected client only. +func (c *Client) sendHistory() { + rows, err := database.GetRecentChat(c.hub.db, historyLimit) + if err != nil { + log.Println("ws: failed to load history", err) + return + } + + history := make([]Message, 0, len(rows)) + for _, row := range rows { + history = append(history, Message{ + Type: "chat", + Nick: row.Nick, + Body: row.Body, + At: row.CreatedAt.UnixMilli(), + }) + } + + payload, err := json.Marshal(Message{Type: "history", History: history}) + if err != nil { + log.Println("ws: marshal history error", err) + return + } + c.send <- payload +} + +// ServeWSContinuation upgrades the request to a websocket and wires the client +// into the hub. It's terminal in a route chain β after upgrade there's no HTTP +// response left to render. +func ServeWSContinuation(hub *Hub) types.Continuation { + return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + conn, err := upgrader.Upgrade(resp, req, nil) + if err != nil { + log.Println("ws: upgrade failed", err) + return failure(context, req, resp) + } + + client := &Client{ + hub: hub, + conn: conn, + send: make(chan []byte, 256), + userID: context.User.ID, + } + + // Queue history first (send is buffered), then announce the join. + client.sendHistory() + client.hub.register <- client + + go client.writePump() + go client.readPump() + + return success(context, req, resp) + } + } +} diff --git a/args/args.go b/args/args.go new file mode 100644 index 0000000..6655f4a --- /dev/null +++ b/args/args.go @@ -0,0 +1,58 @@ +package args + +import ( + "flag" + "os" +) + +type Arguments struct { + DatabasePath string + TemplatePath string + StaticPath string + + Migrate bool + Scheduler bool + + Port int + Server bool + + // 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 + + // Minecraft RCON, for the server status panel. Empty address disables it. + RconAddress string + RconPassword string + + // LanSubnet (env LAN_SUBNET, e.g. 192.168.8.0/24) is swept to populate the + // ARP table for idle devices. Empty disables the sweep. + LanSubnet string +} + +func GetArgs() (*Arguments, error) { + databasePath := flag.String("database-path", "./db/penguins.db", "Path to the SQLite database") + templatePath := flag.String("template-path", "./templates", "Path to the template directory") + staticPath := flag.String("static-path", "./static", "Path to the static directory") + + migrate := flag.Bool("migrate", false, "Run the migrations on startup") + scheduler := flag.Bool("scheduler", false, "Run the background scheduler (ARP refresh, session cleanup)") + + port := flag.Int("port", 8080, "Port to listen on") + server := flag.Bool("server", false, "Run the HTTP server") + + flag.Parse() + + return &Arguments{ + DatabasePath: *databasePath, + TemplatePath: *templatePath, + StaticPath: *staticPath, + Migrate: *migrate, + Scheduler: *scheduler, + Port: *port, + Server: *server, + DevMAC: os.Getenv("DEV_MAC"), + RconAddress: os.Getenv("RCON_ADDRESS"), + RconPassword: os.Getenv("RCON_PASSWORD"), + LanSubnet: os.Getenv("LAN_SUBNET"), + }, nil +} diff --git a/database/canvas.go b/database/canvas.go new file mode 100644 index 0000000..57823d4 --- /dev/null +++ b/database/canvas.go @@ -0,0 +1,80 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type Pixel struct { + ID int64 + X int + Y int + Color int +} + +// CanvasSnapshot is a materialized canvas (RGB bytes) plus the id of the last +// pixel baked into it, so replaying just needs pixels with a greater id. +type CanvasSnapshot struct { + Data []byte + ThroughID int64 +} + +func SavePixel(db *sql.DB, userID string, x, y, color int) (int64, error) { + res, err := db.Exec( + "INSERT INTO canvas_pixels (user_id, x, y, color, created_at) VALUES (?, ?, ?, ?, ?)", + userID, x, y, color, time.Now(), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func LatestCanvasSnapshot(db *sql.DB) (*CanvasSnapshot, error) { + var s CanvasSnapshot + err := db.QueryRow("SELECT data, through_id FROM canvas_snapshots ORDER BY id DESC LIMIT 1").Scan(&s.Data, &s.ThroughID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &s, nil +} + +func PixelsAfter(db *sql.DB, throughID int64) ([]Pixel, error) { + rows, err := db.Query("SELECT id, x, y, color FROM canvas_pixels WHERE id > ? ORDER BY id", throughID) + if err != nil { + return nil, err + } + defer rows.Close() + + pixels := []Pixel{} + for rows.Next() { + var p Pixel + if err := rows.Scan(&p.ID, &p.X, &p.Y, &p.Color); err != nil { + return nil, err + } + pixels = append(pixels, p) + } + return pixels, rows.Err() +} + +func CountPixelsAfter(db *sql.DB, throughID int64) (int, error) { + var n int + err := db.QueryRow("SELECT COUNT(*) FROM canvas_pixels WHERE id > ?", throughID).Scan(&n) + return n, err +} + +func SaveCanvasSnapshot(db *sql.DB, data []byte, throughID int64) error { + _, err := db.Exec("INSERT INTO canvas_snapshots (data, through_id, created_at) VALUES (?, ?, ?)", data, throughID, time.Now()) + return err +} + +func DeleteOldCanvasSnapshots(db *sql.DB) error { + _, err := db.Exec("DELETE FROM canvas_snapshots WHERE id NOT IN (SELECT id FROM canvas_snapshots ORDER BY id DESC LIMIT 1)") + return err +} diff --git a/database/chat.go b/database/chat.go new file mode 100644 index 0000000..66550cd --- /dev/null +++ b/database/chat.go @@ -0,0 +1,55 @@ +package database + +import ( + "database/sql" + "log" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type ChatMessage struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Nick string `json:"nick"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` +} + +func GetRecentChat(db *sql.DB, limit int) ([]ChatMessage, error) { + rows, err := db.Query( + `SELECT c.id, c.user_id, u.username, c.body, c.created_at + FROM chat c JOIN users u ON u.id = c.user_id + ORDER BY c.created_at DESC LIMIT ?`, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := []ChatMessage{} + for rows.Next() { + var msg ChatMessage + if err := rows.Scan(&msg.ID, &msg.UserID, &msg.Nick, &msg.Body, &msg.CreatedAt); err != nil { + return nil, err + } + messages = append([]ChatMessage{msg}, messages...) + } + + return messages, rows.Err() +} + +func SaveChatMessage(db *sql.DB, msg *ChatMessage) (*ChatMessage, error) { + log.Println("saving chat message", msg.ID) + + msg.CreatedAt = time.Now() + _, err := db.Exec( + "INSERT INTO chat (id, user_id, body, created_at) VALUES (?, ?, ?, ?)", + msg.ID, msg.UserID, msg.Body, msg.CreatedAt, + ) + if err != nil { + return nil, err + } + return msg, nil +} diff --git a/database/conn.go b/database/conn.go new file mode 100644 index 0000000..4c41a9b --- /dev/null +++ b/database/conn.go @@ -0,0 +1,17 @@ +package database + +import ( + "database/sql" + "log" + + _ "github.com/mattn/go-sqlite3" +) + +func MakeConn(databasePath *string) *sql.DB { + log.Println("opening database at", *databasePath, "with foreign keys enabled") + dbConn, err := sql.Open("sqlite3", *databasePath+"?_foreign_keys=on") + if err != nil { + panic(err) + } + return dbConn +} diff --git a/database/connections.go b/database/connections.go new file mode 100644 index 0000000..e16a17e --- /dev/null +++ b/database/connections.go @@ -0,0 +1,105 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// connectionFreshness is how long a device counts as present after we last saw +// it in the ARP table. It smooths over the kernel briefly aging an entry out. +const connectionFreshness = 60 * time.Second + +type Connection struct { + IP string + Mac string + UpdatedAt time.Time +} + +// RefreshConnections upserts the current ARP snapshot, bumping updated_at for +// devices still present, and prunes ones not seen within the freshness window. +func RefreshConnections(db *sql.DB, ipToMac map[string]string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT INTO connections (ip, mac, updated_at) VALUES (?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET mac = excluded.mac, updated_at = excluded.updated_at`) + if err != nil { + return err + } + defer stmt.Close() + + now := time.Now() + for ip, mac := range ipToMac { + if _, err := stmt.Exec(ip, mac, now); err != nil { + return err + } + } + + if _, err := tx.Exec("DELETE FROM connections WHERE updated_at < ?", now.Add(-connectionFreshness)); err != nil { + return err + } + + return tx.Commit() +} + +func MacForIP(db *sql.DB, ip string) (string, error) { + var mac string + err := db.QueryRow( + "SELECT mac FROM connections WHERE ip = ? AND updated_at > ?", + ip, time.Now().Add(-connectionFreshness), + ).Scan(&mac) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return mac, 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) { + rows, err := db.Query( + "SELECT DISTINCT m.user_id FROM macs m JOIN connections c ON c.mac = m.mac WHERE c.updated_at > ?", + time.Now().Add(-connectionFreshness), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + ids := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids[id] = true + } + return ids, rows.Err() +} + +func ListAliveConnections(db *sql.DB) ([]Connection, error) { + rows, err := db.Query( + "SELECT ip, mac, updated_at FROM connections WHERE updated_at > ? ORDER BY ip", + time.Now().Add(-connectionFreshness), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + conns := []Connection{} + for rows.Next() { + var c Connection + if err := rows.Scan(&c.IP, &c.Mac, &c.UpdatedAt); err != nil { + return nil, err + } + conns = append(conns, c) + } + return conns, rows.Err() +} diff --git a/database/connections_test.go b/database/connections_test.go new file mode 100644 index 0000000..b4ea57b --- /dev/null +++ b/database/connections_test.go @@ -0,0 +1,63 @@ +package database + +import ( + "database/sql" + "path/filepath" + "testing" + "time" +) + +func testDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + if _, err := MigrateConnections(db); err != nil { + t.Fatal(err) + } + return db +} + +func TestMacForIPFreshness(t *testing.T) { + db := testDB(t) + + if err := RefreshConnections(db, map[string]string{"192.168.1.5": "aa:bb:cc:dd:ee:ff"}); err != nil { + t.Fatal(err) + } + if mac, _ := MacForIP(db, "192.168.1.5"); mac != "aa:bb:cc:dd:ee:ff" { + t.Fatalf("fresh entry = %q, want the mac", mac) + } + + // age the entry past the window: it should stop resolving + db.Exec("UPDATE connections SET updated_at = ? WHERE ip = ?", time.Now().Add(-2*connectionFreshness), "192.168.1.5") + if mac, _ := MacForIP(db, "192.168.1.5"); mac != "" { + t.Errorf("stale entry resolved to %q, want empty", mac) + } + + // seeing it again re-activates it + if err := RefreshConnections(db, map[string]string{"192.168.1.5": "aa:bb:cc:dd:ee:ff"}); err != nil { + t.Fatal(err) + } + if mac, _ := MacForIP(db, "192.168.1.5"); mac != "aa:bb:cc:dd:ee:ff" { + t.Errorf("re-seen entry = %q, want the mac", mac) + } +} + +func TestRefreshConnectionsPrunesStale(t *testing.T) { + db := testDB(t) + + RefreshConnections(db, map[string]string{"10.0.0.1": "aa:aa:aa:aa:aa:aa"}) + db.Exec("UPDATE connections SET updated_at = ? WHERE ip = ?", time.Now().Add(-2*connectionFreshness), "10.0.0.1") + + // a refresh with a different device prunes the aged-out one + RefreshConnections(db, map[string]string{"10.0.0.2": "bb:bb:bb:bb:bb:bb"}) + + conns, err := ListAliveConnections(db) + if err != nil { + t.Fatal(err) + } + if len(conns) != 1 || conns[0].IP != "10.0.0.2" { + t.Errorf("after prune = %+v, want only 10.0.0.2", conns) + } +} diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..24d47b6 --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,143 @@ +package database + +import ( + "database/sql" + "log" + + _ "github.com/mattn/go-sqlite3" +) + +type Migrator func(*sql.DB) (*sql.DB, error) + +func MigrateUsers(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating users table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`) + if err != nil { + return dbConn, err + } + + _, err = dbConn.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);`) + 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) { + log.Println("migrating macs table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS macs ( + mac TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + );`) + return dbConn, err +} + +func MigrateSessions(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating sessions table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + );`) + return dbConn, err +} + +func MigrateChat(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating chat table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS chat ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + body TEXT 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 INDEX IF NOT EXISTS idx_chat_created_at ON chat (created_at);`) + return dbConn, err +} + +func MigrateConnections(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating connections table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS connections ( + ip TEXT PRIMARY KEY, + mac TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`) + return dbConn, err +} + +func MigrateMinecraft(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating minecraft_status table") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS minecraft_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + status TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`) + return dbConn, err +} + +func MigrateCanvas(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating canvas tables") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS canvas_pixels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + color INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL + );`) + if err != nil { + return dbConn, err + } + + _, err = dbConn.Exec(`CREATE TABLE IF NOT EXISTS canvas_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + data BLOB NOT NULL, + through_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`) + return dbConn, err +} + +func Migrate(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating database") + + migrations := []Migrator{ + MigrateUsers, + MigrateMacs, + MigrateSessions, + MigrateChat, + MigrateConnections, + MigrateMinecraft, + MigrateCanvas, + } + + for _, migration := range migrations { + var err error + if dbConn, err = migration(dbConn); err != nil { + return dbConn, err + } + } + + return dbConn, nil +} diff --git a/database/minecraft.go b/database/minecraft.go new file mode 100644 index 0000000..d12b4b8 --- /dev/null +++ b/database/minecraft.go @@ -0,0 +1,28 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func SaveMinecraftStatus(db *sql.DB, status string) error { + _, err := db.Exec(`INSERT INTO minecraft_status (id, status, updated_at) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`, + status, time.Now()) + return err +} + +// GetMinecraftStatus returns the cached status JSON and when it was written. An +// empty string (with no error) means nothing has been cached yet. +func GetMinecraftStatus(db *sql.DB) (string, time.Time, error) { + var status string + var updatedAt time.Time + err := db.QueryRow("SELECT status, updated_at FROM minecraft_status WHERE id = 1").Scan(&status, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return "", time.Time{}, nil + } + return status, updatedAt, err +} diff --git a/database/sessions.go b/database/sessions.go new file mode 100644 index 0000000..5644281 --- /dev/null +++ b/database/sessions.go @@ -0,0 +1,44 @@ +package database + +import ( + "database/sql" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +type Session struct { + ID string + UserID string + ExpireAt time.Time +} + +func CreateSession(db *sql.DB, id, userID string, ttl time.Duration) (*Session, error) { + now := time.Now() + expire := now.Add(ttl) + _, err := db.Exec( + "INSERT INTO sessions (id, user_id, created_at, expire_at) VALUES (?, ?, ?, ?)", + id, userID, now, expire, + ) + if err != nil { + return nil, err + } + return &Session{ID: id, UserID: userID, ExpireAt: expire}, nil +} + +func GetSessionUser(db *sql.DB, id string) (*User, error) { + return scanUser(db.QueryRow( + "SELECT u.id, u.username, u.created_at, u.last_seen FROM users u JOIN sessions s ON s.user_id = u.id WHERE s.id = ? AND s.expire_at > ?", + id, time.Now(), + )) +} + +func DeleteExpiredSessions(db *sql.DB) error { + _, err := db.Exec("DELETE FROM sessions WHERE expire_at < ?", time.Now()) + return err +} + +func DeleteSession(db *sql.DB, id string) error { + _, err := db.Exec("DELETE FROM sessions WHERE id = ?", id) + return err +} diff --git a/database/users.go b/database/users.go new file mode 100644 index 0000000..169cce1 --- /dev/null +++ b/database/users.go @@ -0,0 +1,101 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var ErrNotFound = errors.New("not found") + +type User struct { + ID string + Username string + CreatedAt time.Time + LastSeen time.Time +} + +func scanUser(row *sql.Row) (*User, error) { + var u User + err := row.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.LastSeen) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +const userColumns = "id, username, created_at, last_seen" + +func CreateUser(db *sql.DB, id, username string) (*User, error) { + now := time.Now() + _, err := db.Exec( + "INSERT INTO users ("+userColumns+") VALUES (?, ?, ?, ?)", + id, username, now, now, + ) + if err != nil { + return nil, err + } + return &User{ID: id, Username: username, CreatedAt: now, LastSeen: now}, nil +} + +func GetUserByID(db *sql.DB, id string) (*User, error) { + return scanUser(db.QueryRow("SELECT "+userColumns+" FROM users WHERE id = ?", id)) +} + +func RenameUser(db *sql.DB, userID, username string) error { + _, err := db.Exec("UPDATE users SET username = ? WHERE id = ?", username, userID) + return err +} + +func ListUsers(db *sql.DB) ([]User, error) { + rows, err := db.Query("SELECT " + userColumns + " FROM users ORDER BY last_seen DESC") + if err != nil { + return nil, err + } + defer rows.Close() + + users := []User{} + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt, &u.LastSeen); err != nil { + return nil, err + } + users = append(users, u) + } + return users, rows.Err() +} + +func GetUserByUsername(db *sql.DB, username string) (*User, error) { + return scanUser(db.QueryRow("SELECT "+userColumns+" FROM users WHERE username = ?", username)) +} + +// GetUserByMAC finds the account a hardware address currently belongs to. +// Columns are alias-qualified because macs also has a last_seen column. +func GetUserByMAC(db *sql.DB, mac string) (*User, error) { + return scanUser(db.QueryRow( + "SELECT u.id, u.username, u.created_at, u.last_seen FROM users u JOIN macs m ON m.user_id = u.id WHERE m.mac = ?", + mac, + )) +} + +// TouchUser bumps a user's last_seen β this is the timestamp the portal shows +// when someone tries to reclaim a taken handle. +func TouchUser(db *sql.DB, id string) error { + _, err := db.Exec("UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id) + return err +} + +// AssociateMAC binds a MAC to a user, moving it from any previous owner. This is +// what "logging back in" from a rotated MAC does. +func AssociateMAC(db *sql.DB, mac, userID string) error { + now := time.Now() + _, err := db.Exec(`INSERT INTO macs (mac, user_id, first_seen, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET user_id = excluded.user_id, last_seen = excluded.last_seen`, + mac, userID, now, now) + return err +} @@ -0,0 +1,16 @@ +module penguins.lan + +go 1.26 + +require ( + github.com/go-co-op/gocron v1.37.0 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.22 +) + +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 +) @@ -0,0 +1,44 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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/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= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +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= +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= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -0,0 +1,51 @@ +package main + +import ( + "log" + + "github.com/joho/godotenv" + + "penguins.lan/api" + "penguins.lan/args" + "penguins.lan/database" + "penguins.lan/scheduled" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + if err := godotenv.Load(); err != nil { + log.Println("no .env file loaded:", err) + } + + argv, err := args.GetArgs() + if err != nil { + log.Fatal(err) + } + + dbConn := database.MakeConn(&argv.DatabasePath) + defer dbConn.Close() + + if argv.Migrate { + if _, err := database.Migrate(dbConn); err != nil { + log.Fatal(err) + } + log.Println("database migrated successfully") + } + + if argv.Scheduler { + scheduled.StartScheduler(argv, dbConn) + log.Println("scheduler started") + } + + if argv.Server { + server := api.MakeServer(argv, dbConn) + log.Printf("listening on :%d", argv.Port) + + if err := server.ListenAndServe(); err != nil { + log.Fatal(err) + } + } + + select {} +} diff --git a/scheduled/scheduled.go b/scheduled/scheduled.go new file mode 100644 index 0000000..6eb83e4 --- /dev/null +++ b/scheduled/scheduled.go @@ -0,0 +1,86 @@ +package scheduled + +import ( + "database/sql" + "encoding/json" + "log" + "time" + + "github.com/go-co-op/gocron" + + "penguins.lan/adapters/arp" + "penguins.lan/adapters/netscan" + "penguins.lan/api/minecraft" + "penguins.lan/args" + "penguins.lan/database" +) + +const sweepTimeout = 1 * time.Second + +func StartScheduler(argv *args.Arguments, dbConn *sql.DB) { + scheduler := gocron.NewScheduler(time.Local) + + scheduler.Every(1).Seconds().Do(func() { + if err := refreshArp(dbConn, arp.SystemProvider{}); err != nil { + log.Println("arp refresh failed:", err) + } + }) + + if argv.LanSubnet != "" { + go sweep(argv.LanSubnet) + scheduler.Every(30).Seconds().Do(func() { sweep(argv.LanSubnet) }) + } + + if argv.RconAddress != "" { + go refreshMinecraft(dbConn, argv.RconAddress, argv.RconPassword) + scheduler.Every(10).Seconds().Do(func() { + refreshMinecraft(dbConn, argv.RconAddress, argv.RconPassword) + }) + } + + scheduler.Every(500).Seconds().Do(func() { + if err := database.DeleteExpiredSessions(dbConn); err != nil { + log.Println("session cleanup failed:", err) + } + }) + + scheduler.StartAsync() +} + +func refreshArp(dbConn *sql.DB, provider arp.TableProvider) error { + table, err := provider.Fetch() + if err != nil { + return err + } + + ipToMac := make(map[string]string, len(table.AddrToMac)) + for ip, mac := range table.AddrToMac { + ipToMac[ip.Format()] = mac.Format() + } + + return database.RefreshConnections(dbConn, ipToMac) +} + +func sweep(subnet string) { + if err := netscan.Sweep(subnet, sweepTimeout); err != nil { + log.Println("lan sweep failed:", err) + } +} + +func refreshMinecraft(dbConn *sql.DB, address, password string) { + status, err := minecraft.Query(address, password) + if err != nil { + log.Println("minecraft query failed:", err) + status = minecraft.Status{Online: false} + } + + blob, err := json.Marshal(status) + if err != nil { + log.Println("minecraft marshal failed:", err) + return + } + + if err := database.SaveMinecraftStatus(dbConn, string(blob)); err != nil { + log.Println("minecraft save failed:", err) + } +} diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..53f7abd --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,128 @@ +:root { + --bg: #0d1b2a; + --panel: #1b263b; + --ink: #e0e1dd; + --muted: #8d99ae; + --accent: #48cae4; + --accent-2: #ffd166; + --error: #ef476f; + --ok: #06d6a0; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + line-height: 1.5; +} + +.container { + max-width: 760px; + margin: 0 auto; + padding: 1.5rem 1rem 3rem; +} + +a { color: var(--accent); } +a:hover { color: var(--accent-2); } + +h1 { font-size: 1.4rem; margin: 0 0 .5rem; } +h2 { color: var(--accent); } +hr { border: none; border-top: 1px dashed var(--muted); margin: 1rem 0; } + +header nav { + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} +.spacer { flex: 1 1 auto; } + +.nick-form { + display: flex; + align-items: center; + gap: .35rem; +} +.nick-form label { color: var(--muted); } + +input, textarea, button { + font: inherit; + border: 1px solid var(--muted); + background: var(--panel); + color: var(--ink); + padding: .35rem .5rem; +} +input:focus, textarea:focus { outline: 2px solid var(--accent); } +button { + cursor: pointer; + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: bold; +} +button:hover { background: var(--accent-2); border-color: var(--accent-2); } + +.marquee { color: var(--accent-2); background: var(--panel); padding: .25rem; } + +pre { + background: var(--panel); + padding: .75rem; + overflow-x: auto; +} + +.info { padding: .5rem .75rem; margin: .5rem 0; } +.success { background: rgba(6, 214, 160, .15); border: 1px solid var(--ok); } +.error { background: rgba(239, 71, 111, .15); border: 1px solid var(--error); } + +.status { color: var(--muted); } +.status.ok { color: var(--ok); } + +/* chat */ +.chat-log { + height: 20vh; + overflow-y: auto; + background: var(--panel); + padding: .75rem; + margin-bottom: .75rem; +} +.chat-line { margin: .15rem 0; word-wrap: break-word; } +.chat-line.mine .chat-nick { color: var(--accent-2); } +.chat-nick { color: var(--accent); font-weight: bold; } +.chat-time { color: var(--muted); font-size: .75rem; } +.chat-system { color: var(--muted); font-style: italic; margin: .25rem 0; } +.chat-form { display: flex; gap: .5rem; } +.chat-form input { flex: 1 1 auto; } + +/* guestbook */ +.guestbook-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; } +.guestbook-form textarea { width: 100%; min-height: 4rem; } +.guestbook-entries { list-style: none; padding: 0; } +.guestbook-entries li { background: var(--panel); padding: .5rem .75rem; margin: .5rem 0; } +.entry-message { white-space: pre-wrap; word-wrap: break-word; } +.entry-meta { color: var(--muted); font-size: .8rem; margin-top: .25rem; } + +/* portal */ +.portal-form { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; margin: .75rem 0; } +.rename-form { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin: .5rem 0; } +.roster { list-style: none; padding: 0; } +.roster li { padding: .15rem 0; } +.legend { color: var(--muted); font-size: .85rem; display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; } +.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.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; } + +/* 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; } + +footer { color: var(--muted); } diff --git a/static/img/favicon.svg b/static/img/favicon.svg new file mode 100644 index 0000000..9e00805 --- /dev/null +++ b/static/img/favicon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> + <rect width="100" height="100" rx="18" fill="#0d1b2a"/> + <text x="50" y="50" font-size="58" text-anchor="middle" dominant-baseline="central">π§</text> +</svg> diff --git a/static/img/lab.png b/static/img/lab.png Binary files differnew file mode 100644 index 0000000..dfd0e3d --- /dev/null +++ b/static/img/lab.png diff --git a/static/js/canvas.js b/static/js/canvas.js new file mode 100644 index 0000000..f164803 --- /dev/null +++ b/static/js/canvas.js @@ -0,0 +1,97 @@ +(function () { + 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"; + + // 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 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. + let loaded = false; + const queue = []; + function apply(msg) { + if (msg.type === "place") drawPixel(msg.x, msg.y, hexFromInt(msg.c)); + else if (msg.type === "cooldown") startCooldown(msg.ms); + } + + let ws; + 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.onclose = () => setTimeout(connect, 1000); + ws.onerror = () => ws.close(); + } + connect(); + + fetch("/canvas/state") + .then((r) => r.arrayBuffer()) + .then((buf) => { + 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 + 1] = bytes[p * 3 + 1]; + img.data[p * 4 + 2] = bytes[p * 3 + 2]; + img.data[p * 4 + 3] = 255; + } + ctx.putImageData(img, 0, 0); + loaded = true; + queue.forEach(apply); + queue.length = 0; + }); + + // cooldown indicator (server enforces; this is UI feedback) + let cooldownUntil = 0; + const cdEl = document.getElementById("cooldown"); + function startCooldown(ms) { + cooldownUntil = Math.max(cooldownUntil, Date.now() + ms); + tick(); + } + function tick() { + const rem = cooldownUntil - Date.now(); + if (rem > 0) { + cdEl.textContent = `cooldown: ${(rem / 1000).toFixed(1)}s`; + requestAnimationFrame(tick); + } else { + cdEl.textContent = ""; + } + } + + canvas.addEventListener("click", (e) => { + 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; + ws.send(JSON.stringify({ type: "place", x, y, i: selected })); + startCooldown(cfg.cooldownMs); + }); +})(); diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000..69c169c --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,101 @@ +(function () { + const log = document.getElementById("log"); + const form = document.getElementById("chat-form"); + const input = document.getElementById("msg"); + const status = document.getElementById("status"); + const me = document.getElementById("me").textContent.trim(); + + let socket; + let reconnectDelay = 500; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function atBottom() { + return log.scrollHeight - log.scrollTop - log.clientHeight < 40; + } + + function append(node) { + const stick = atBottom(); + log.appendChild(node); + if (stick) log.scrollTop = log.scrollHeight; + } + + function fmtTime(ms) { + if (!ms) return ""; + const d = new Date(ms); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + function renderChat(msg) { + const line = el("div", "chat-line"); + if (msg.nick === me) line.classList.add("mine"); + line.appendChild(el("span", "chat-nick", msg.nick)); + line.appendChild(el("span", "chat-body", " " + msg.body)); + line.appendChild(el("span", "chat-time", " " + fmtTime(msg.at))); + append(line); + } + + function renderSystem(msg) { + append(el("div", "chat-system", msg.body)); + } + + function handle(msg) { + switch (msg.type) { + case "history": + log.innerHTML = ""; + (msg.history || []).forEach(renderChat); + break; + case "chat": + renderChat(msg); + break; + case "system": + renderSystem(msg); + break; + } + } + + function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + socket = new WebSocket(`${proto}://${location.host}/ws`); + + socket.onopen = function () { + status.textContent = "connected β"; + status.className = "status ok"; + reconnectDelay = 500; + }; + + socket.onmessage = function (event) { + try { + handle(JSON.parse(event.data)); + } catch (e) { + console.error("bad message", e); + } + }; + + socket.onclose = function () { + status.textContent = "reconnectingβ¦"; + status.className = "status"; + setTimeout(connect, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 8000); + }; + + socket.onerror = function () { + socket.close(); + }; + } + + form.addEventListener("submit", function (e) { + e.preventDefault(); + const body = input.value.trim(); + if (!body || !socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "chat", body: body })); + input.value = ""; + }); + + connect(); +})(); diff --git a/static/js/minecraft.js b/static/js/minecraft.js new file mode 100644 index 0000000..757053c --- /dev/null +++ b/static/js/minecraft.js @@ -0,0 +1,30 @@ +(function () { + const el = document.getElementById("mc-status"); + if (!el) return; + + function escape(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + async function poll() { + try { + const r = await fetch("/minecraft", { headers: { Accept: "application/json" } }); + if (!r.ok) return; + const s = await r.json(); + if (!s.online) { + el.textContent = "server offline"; + } else if (s.count === 0) { + el.textContent = `online β 0/${s.max} players`; + } else { + el.innerHTML = `online β ${s.count}/${s.max}: ` + s.players.map(escape).join(", "); + } + } catch (e) { + /* keep the last render */ + } + } + + poll(); + setInterval(poll, 10000); +})(); diff --git a/static/js/waddlers.js b/static/js/waddlers.js new file mode 100644 index 0000000..d1dbd63 --- /dev/null +++ b/static/js/waddlers.js @@ -0,0 +1,30 @@ +(function () { + const roster = document.getElementById("roster"); + if (!roster) return; + + function escape(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + function render(list) { + roster.innerHTML = list + .map( + (w) => + `<li><span class="dot ${w.status}"></span> <strong>${escape(w.username)}</strong> <span class="muted">β last seen ${escape(w.lastSeen)}</span></li>` + ) + .join(""); + } + + async function poll() { + try { + const r = await fetch("/waddlers", { headers: { Accept: "application/json" } }); + if (r.ok) render(await r.json()); + } catch (e) { + console.error(e); + } + } + + setInterval(poll, 2000); +})(); diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..e4bb5e2 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,4 @@ +{{ define "content" }} +<h2>404 β lost on the ice</h2> +<p><em>this page waddled off somewhere. <a href="/">head back home</a>.</em></p> +{{ end }} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3a9f582 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,46 @@ +{{ define "base" }} +<!DOCTYPE html> +<html lang="en"> + <head> + <title>penguins.lan~</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link rel="icon" href="/static/img/favicon.svg"> + <link rel="stylesheet" type="text/css" href="/static/css/styles.css"> + </head> + <body> + <div id="content" class="container"> + <header> + <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> + <span> | </span> + <a href="/logout">logout.</a> + {{ end }} + </nav> + </header> + <hr> + + <main> + {{ if and .Success (gt (len .Success.Messages) 0) }} + {{ range $message := .Success.Messages }} + <div class="info success">{{ $message }}</div> + {{ end }} + {{ end }} + {{ if and .Error (gt (len .Error.Messages) 0) }} + {{ range $error := .Error.Messages }} + <div class="info error">{{ $error }}</div> + {{ end }} + {{ end }} + + {{ template "content" . }} + </main> + </div> + </body> +</html> +{{ end }} diff --git a/templates/base_empty.html b/templates/base_empty.html new file mode 100644 index 0000000..adda790 --- /dev/null +++ b/templates/base_empty.html @@ -0,0 +1,3 @@ +{{ define "base" }} + {{ template "content" . }} +{{ end }} diff --git a/templates/canvas.html b/templates/canvas.html new file mode 100644 index 0000000..84e964d --- /dev/null +++ b/templates/canvas.html @@ -0,0 +1,22 @@ +{{ 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 <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 new file mode 100644 index 0000000..aeb2739 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,53 @@ +{{ define "content" }} +<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> + +<div style="text-align: center" > + <img width="60%" src="/static/img/lab.png"> +</div> + +<p>wanna fuck up this website? the code is available to you to fuck up. go crazy (put in a reverse shell if you want for all i care), but please be courteous <3.</p> +<pre> +git clone you@penguins.lan:~/penguins.lan +cd penguins.lan +vim templates/home.html +git add . && git commit -m "HAHAHA" +git push +</pre> + +<marquee class="marquee">* LONG LIVE THE MARQUEE TAG * THEY CAN'T TAKE IT FROM US *</marquee> + +<hr> + +<h3>~waddlers</h3> +<p class="legend"> + <span class="dot chat"></span> chatting + <span class="dot network"></span> online + <span class="dot offline"></span> offline +</p> +<ul class="roster" id="roster"> + {{ range .Users }} + <li><span class="dot {{ .Status }}"></span> <strong>{{ .Username }}</strong> <span class="muted">β last seen {{ .LastSeen }}</span></li> + {{ end }} +</ul> +<script src="/static/js/waddlers.js"></script> + +<div id="log" class="chat-log" aria-live="polite"></div> + +<form id="chat-form" class="chat-form" autocomplete="off"> + <input id="msg" name="msg" placeholder="hello fellow penguins!" maxlength="500" autofocus> + <button type="submit">send</button> +</form> + +<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 new file mode 100644 index 0000000..a2959f1 --- /dev/null +++ b/templates/portal.html @@ -0,0 +1,66 @@ +{{ define "content" }} +<p>welcome to ~penguins.lan</p> + +{{ with .Portal }} + {{ if .Taken }} + <div class="info"> + <form action="/portal" method="post"> + <p><strong>{{ .RequestedName }}</strong> is already taken, last seen <strong>{{ .LastSeen }}</strong>. is that you?</p> + <input type="hidden" name="username" value="{{ .RequestedName }}"> + <input type="hidden" name="reclaim" value="1"> + <button>yes, i am {{ .RequestedName }}</button> + </form> + </div> + {{ end }} + <br> + <form action="/portal" method="post" id="loginForm"> + <label for="username">{{ if .Taken }} no? {{ end }} i want to be called</label> + <input id="username" name="username" placeholder="{{ .SuggestedName }}" maxlength="24" autofocus> + <button type="submit">enter</button> + </form> + + {{ if .DetectedMAC }} + <p class="muted"><small>this device: <code>{{ .DetectedMAC }}</code>.</small></p> + {{ end }} +{{ end }} +{{ end }} + +<script> + const urlParamsText = window.location.href.split('?')[1]; + const urlParams = new URLSearchParams(urlParamsText); + const paramsObj = Object.fromEntries(urlParams.entries()); + if (paramsObject['_token'] && paramsObject['_authaction']) { + const form = document.getElementById("loginForm"); + const isEmpty = (formData, keys) => { + const obj = Object.fromEntries(formData.entries()); + if (keys === undefined) keys = Object.keys(obj); + return keys.every(key => !obj[key]); + } + const send = async () => { + await fetch(paramsObject['_authaction'] + '?tok' + paramsObject['_token'], { + mode: "no-cors" + }); + const data = new FormData(form); + await fetch(form.action, { + method: form.method, + body: data + }); + }; + form.addEventListener("submit", (e) => { + 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 diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..f0f93d9 --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,8 @@ +hellow +{{ define "content" }} +<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 diff --git a/templates/waddlers.html b/templates/waddlers.html new file mode 100644 index 0000000..69d70bd --- /dev/null +++ b/templates/waddlers.html @@ -0,0 +1,3 @@ +{{ range .Users }} +<li><strong>{{ .Username }}</strong> <span class="muted">β last seen {{ .LastSeen }}</span></li> +{{ end }}
\ No newline at end of file diff --git a/utils/net.go b/utils/net.go new file mode 100644 index 0000000..76454bd --- /dev/null +++ b/utils/net.go @@ -0,0 +1,59 @@ +package utils + +import ( + "fmt" + "strings" +) + +// Mac is a 48-bit hardware address. +type Mac struct { + Addr [6]byte +} + +// Format renders the canonical lowercase, zero-padded form aa:bb:cc:dd:ee:ff. +func (m *Mac) Format() 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) { + mac := &Mac{} + t := strings.TrimSpace(s) + n, err := fmt.Sscanf(t, "%x:%x:%x:%x:%x:%x", + &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 mac %q: parsed %d of 6 octets", s, n) + } + return mac, nil +} + +// INet4 is an IPv4 address. +type INet4 struct { + Addr [4]byte +} + +// Format renders the dotted-decimal form a.b.c.d. +func (n *INet4) Format() string { + return fmt.Sprintf("%d.%d.%d.%d", n.Addr[0], n.Addr[1], n.Addr[2], n.Addr[3]) +} + +// ParseINet4 reads a dotted-decimal IPv4 address. Out-of-range octets (>255) +// overflow the byte and are rejected. +func ParseINet4(s string) (*INet4, error) { + addr := &INet4{} + t := strings.TrimSpace(s) + n, err := fmt.Sscanf(t, "%d.%d.%d.%d", + &addr.Addr[0], &addr.Addr[1], &addr.Addr[2], &addr.Addr[3]) + if err != nil { + return nil, err + } + if n != 4 { + return nil, fmt.Errorf("invalid ipv4 %q: parsed %d of 4 octets", s, n) + } + return addr, nil +} diff --git a/utils/net_test.go b/utils/net_test.go new file mode 100644 index 0000000..27d991d --- /dev/null +++ b/utils/net_test.go @@ -0,0 +1,63 @@ +package utils + +import "testing" + +func TestParseMacAndFormat(t *testing.T) { + // each input should parse and round-trip to the canonical form + canonical := map[string]string{ + "aa:bb:cc:dd:ee:ff": "aa:bb:cc:dd:ee:ff", // already canonical + "AA:BB:CC:DD:EE:FF": "aa:bb:cc:dd:ee:ff", // uppercase + "8:0:27:ab:cd:ef": "08:00:27:ab:cd:ef", // macOS zero-stripped octets + " de:ad:be:ef:00:01 ": "de:ad:be:ef:00:01", // surrounding space + } + for in, want := range canonical { + mac, err := ParseMac(in) + if err != nil { + t.Errorf("ParseMac(%q) unexpected error: %v", in, err) + continue + } + if got := mac.Format(); got != want { + t.Errorf("ParseMac(%q).Format() = %q, want %q", in, got, want) + } + } +} + +func TestParseMacInvalid(t *testing.T) { + for _, in := range []string{ + "", // empty + "aa:bb:cc:dd:ee", // too few octets + "hello", // not a mac + "zz:zz:zz:zz:zz:zz", // non-hex digits + } { + if _, err := ParseMac(in); err == nil { + t.Errorf("ParseMac(%q) = nil error, want error", in) + } + } +} + +func TestParseINet4AndFormat(t *testing.T) { + for _, ip := range []string{"192.168.1.5", "10.0.0.2", "0.0.0.0", "255.255.255.255"} { + addr, err := ParseINet4(ip) + if err != nil { + t.Errorf("ParseINet4(%q) unexpected error: %v", ip, err) + continue + } + if got := addr.Format(); got != ip { + t.Errorf("ParseINet4(%q).Format() = %q, want %q", ip, got, ip) + } + } +} + +func TestParseINet4Invalid(t *testing.T) { + for _, in := range []string{ + "", // empty + "1.2.3", // too few octets + "192.168.1.256", // octet overflows a byte + "::1", // IPv6, not dotted-decimal + "hello", // garbage + } { + if _, err := ParseINet4(in); err == nil { + t.Errorf("ParseINet4(%q) = nil error, want error", in) + } + } +} diff --git a/utils/random_id.go b/utils/random_id.go new file mode 100644 index 0000000..e6dc731 --- /dev/null +++ b/utils/random_id.go @@ -0,0 +1,15 @@ +package utils + +import ( + "crypto/rand" + "fmt" +) + +// RandomId returns a 128-bit hex id, handy for sessions, db rows, etc. +func RandomId() string { + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + panic(err) + } + return fmt.Sprintf("%x", id) +} diff --git a/utils/text.go b/utils/text.go new file mode 100644 index 0000000..5a79ec9 --- /dev/null +++ b/utils/text.go @@ -0,0 +1,49 @@ +package utils + +import ( + "fmt" + "math/rand" + "strings" + "time" +) + +var nameAdjectives = []string{"waddly", "frosty", "noot", "sleepy", "tuxedo", "briny", "squeaky", "blizzard", "pebble", "slippy"} +var nameNouns = []string{"penguin", "gentoo", "rockhopper", "emperor", "macaroni", "chinstrap", "skua", "krill", "iceberg", "fishstick"} + +func SuggestName() string { + return nameAdjectives[rand.Intn(len(nameAdjectives))] + "-" + nameNouns[rand.Intn(len(nameNouns))] +} + +func Sanitize(raw string) string { + return strings.ReplaceAll(strings.TrimSpace(raw), "\n", " ") +} + +func SanitizeName(raw string) string { + clean := strings.ToLower(Sanitize(raw)) + clean = strings.ReplaceAll(clean, " ", "-") + if len(clean) > 24 { + clean = clean[:24] + } + return clean +} + +func FormatSince(t time.Time) string { + d := time.Since(t) + switch { + case d < time.Minute && d.Seconds() < 20: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%d minutes ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%d hours ago", int(d.Hours())) + default: + return fmt.Sprintf("%d days ago", int(d.Hours()/24)) + } +} + +func Truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} |
