// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package control import ( "encoding/json" "io" "log/slog" "net/http" "net/netip" "strings" "time" "echo-lot.app/server/internal/store" ) // The dev relay: an Echolot instance on a test LAN reports where adbd's wireless-debug listener // can be reached, and a developer on another subnet reads it back from the admin UI. Documented in // server/README.md; deliberately absent from probe-protocol.md and from the advertised capability // list, because it measures nothing — it is scaffolding for driving a test device. // // It sits on the control plane rather than on a listener of its own, and that is the whole point. // The receiver this replaces was a separate service wildcard-bound to 0.0.0.0:443, which silently // occupied port 443 on the addresses reserved for measurement and voided the IPv4 interception // proof for as long as it ran — and it accepted a port report from anyone who could reach it. Here // there is no new port, no wildcard bind, and the device credential authenticates the submitter. // maxADBNoteLen and maxADBNameLen bound what a submitter can store. Free text from a device ends // up in the state file and on an operator's page; a label is a few words, so a few words is what // is kept. const ( maxADBNameLen = 64 maxADBNoteLen = 200 ) // submitADBEndpoint records the calling device's wireless-debug endpoint (POST // /v1/devtools/adb-endpoint). Newest report per device wins; see store.PutADBEndpoint. func (s *Server) submitADBEndpoint(w http.ResponseWriter, r *http.Request) { dev := s.Store.DeviceByCredential(bearer(r)) if dev == nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unknown credential"}) return } var body struct { Host string `json:"host"` Port int `json:"port"` DeviceName string `json:"device_name"` Note string `json:"note"` } // A few hundred bytes of JSON at most; the limit is here so a stuck or hostile client cannot // stream a body at a handler that has no reason to read one. if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad body"}) return } host := strings.TrimSpace(body.Host) if !plausibleHost(host) { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "host must be an IP address or a hostname", }) return } if body.Port < 1 || body.Port > 65535 { writeJSON(w, http.StatusBadRequest, map[string]string{ "error": "port must be between 1 and 65535", }) return } e := store.ADBEndpoint{ Device: dev.ID, Host: host, Port: body.Port, DeviceName: clip(body.DeviceName, maxADBNameLen), Note: clip(body.Note, maxADBNoteLen), ReportedAt: time.Now().UTC(), SourceIP: remoteIP(r), } if err := s.Store.PutADBEndpoint(e); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } slog.Info("adb endpoint reported", "device", dev.ID, "name", e.DeviceName, "host", e.Host, "port", e.Port, "from", e.SourceIP) writeJSON(w, http.StatusCreated, map[string]any{ "device": e.Device, "host": e.Host, "port": e.Port, "reported_at": e.ReportedAt.Format(time.RFC3339), }) } // plausibleHost accepts an IP literal or a DNS-shaped name. // // A shape check and nothing more: the address is meaningful only on the reporter's own LAN, so // this server can never confirm it is reachable or even real. What it can do is refuse a value // that could not be an address at all, which keeps junk out of the state file and out of the // command an operator is about to paste into a shell. func plausibleHost(h string) bool { if h == "" || len(h) > 253 { return false } if _, err := netip.ParseAddr(strings.Trim(h, "[]")); err == nil { return true } for _, label := range strings.Split(strings.TrimSuffix(h, "."), ".") { if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return false } for i := 0; i < len(label); i++ { c := label[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-': default: return false } } } return true } // clip trims a caller-supplied label to a bounded length. func clip(s string, n int) string { s = strings.TrimSpace(s) if len(s) > n { return s[:n] } return s }