// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package store import ( "sort" "time" ) // ADBEndpoint is one device's report of where its wireless-debug listener can be reached. // // Dev tooling, not measurement: mDNS does not cross subnets, so an Echolot instance sitting on the // test LAN discovers adbd's rotating port there and relays it here for a developer on another // subnet. It lives in the same state file as devices and tokens because it is a handful of rows — // a second file, or a database, would be more machinery than the data deserves. type ADBEndpoint struct { // Device is the id of the *submitting* device, taken from its credential rather than from the // body: the report is keyed by who sent it, so nobody can overwrite anyone else's row. Device string `json:"device"` Host string `json:"host"` Port int `json:"port"` DeviceName string `json:"device_name,omitempty"` Note string `json:"note,omitempty"` ReportedAt time.Time `json:"reported_at"` // SourceIP is where the report arrived from, as this server observed it — the one field here // the submitter does not get to choose, and the one that says which side of the NAT it sent from. SourceIP string `json:"source_ip,omitempty"` } // maxADBEndpoints bounds the table. One row per device under test is the expected scale; the cap // is what stops a fleet of enrolled devices from turning a debugging aid into unbounded state. const maxADBEndpoints = 16 // SetADBEndpointRetention sets how long relayed endpoints are kept; <= 0 keeps them until the // device replaces its own row or the cap evicts it. // // Not persisted: it is configuration (ECHOLOT_ADB_ENDPOINT_RETENTION_H), not state, and reading it // back out of the state file would let a stale copy outlive the operator's decision. func (s *Store) SetADBEndpointRetention(d time.Duration) { s.mu.Lock() defer s.mu.Unlock() s.adbRetention = d } // PutADBEndpoint records a device's endpoint, replacing whatever it reported before. // // Newest wins per device rather than appending: a rotated port makes the previous one wrong, not // historical, and an operator reading a list of dead ports would try them. func (s *Store) PutADBEndpoint(e ADBEndpoint) error { s.mu.Lock() defer s.mu.Unlock() if e.ReportedAt.IsZero() { e.ReportedAt = time.Now().UTC() } s.dropExpiredADBLocked(e.ReportedAt) replaced := false for i := range s.data.ADBEndpoints { if s.data.ADBEndpoints[i].Device == e.Device { s.data.ADBEndpoints[i] = e replaced = true break } } if !replaced { s.data.ADBEndpoints = append(s.data.ADBEndpoints, e) } // Oldest first out when the cap is reached: the row nobody has refreshed is the one least // likely to still describe a listening port. if len(s.data.ADBEndpoints) > maxADBEndpoints { sort.SliceStable(s.data.ADBEndpoints, func(i, j int) bool { return s.data.ADBEndpoints[i].ReportedAt.Before(s.data.ADBEndpoints[j].ReportedAt) }) s.data.ADBEndpoints = s.data.ADBEndpoints[len(s.data.ADBEndpoints)-maxADBEndpoints:] } return s.save() } // ADBEndpoints returns the live reports, newest first. func (s *Store) ADBEndpoints() []ADBEndpoint { s.mu.Lock() defer s.mu.Unlock() before := len(s.data.ADBEndpoints) s.dropExpiredADBLocked(time.Now().UTC()) if len(s.data.ADBEndpoints) != before { // The read path writes too: an address that has expired should stop existing in the file, // not merely be filtered out of the answer. A failed save leaves the returned view correct // and the next write to retry, so it is not worth failing a read over. _ = s.save() } out := append([]ADBEndpoint(nil), s.data.ADBEndpoints...) sort.SliceStable(out, func(i, j int) bool { return out[i].ReportedAt.After(out[j].ReportedAt) }) return out } // dropExpiredADBLocked enforces the retention window. // // A LAN address earns an expiry that a device id does not: it describes the inside of somebody's // home or office network — which subnet, which host, which port a debug shell answers on — and it // stops being true within minutes, because adbd rotates the port. Keeping it after that trades // every bit of that disclosure for nothing at all. Applied on write and on read, whichever comes // first, so an idle server still forgets on schedule the moment anyone looks. func (s *Store) dropExpiredADBLocked(now time.Time) { if s.adbRetention <= 0 { return } cutoff := now.Add(-s.adbRetention) kept := s.data.ADBEndpoints[:0] for _, e := range s.data.ADBEndpoints { if e.ReportedAt.After(cutoff) { kept = append(kept, e) } } // Reallocate rather than reslice in place, so the dropped rows are not left addressable in the // old backing array. s.data.ADBEndpoints = append([]ADBEndpoint(nil), kept...) }