Spørgsmålet er ét: hvor mange bruger det her. Svaret er to værdier én gang i døgnet — et tilfældigt id, installationen selv fandt på, og versionen. Ikke andet. Beskeden er lille nok til at stå i fuld længde på indstillingssiden, og den gør det, fordi et løfte om telemetri er præcis så meget værd som læserens mulighed for at efterprøve det. **Ikke obligatorisk, og grunden er praktisk frem for principiel:** det kan ikke håndhæves. Domænet kan blokeres, binæren kan patches, og en fork fjerner den i første commit — så obligatorisk køber intet over slået-til-som-udgangspunkt og koster den tillid, resten af programmet arbejder for. Hemmeligheder forsegles her, nøglen holdes uden for databasen, og sikkerhedskopisiden advarer om, hvad en kopi indeholder. Telemetri, man ikke må sige nej til, er den ene sætning i den historie, der ikke passer. Uenigheden med `config.UpdateCheck`, som er slået fra med den modsatte begrundelse, står skrevet i pakkens egen dokumentation frem for at blive glattet ud. Den er reel, og læseren fortjener at se begge halvdele. Id'et findes på og gemmes ved første brug frem for at blive udledt af værtsnavn eller adresse. En hash af noget om maskinen ville være stabil, bekvem og samtidig en værdi, modtageren kunne holde op mod et gæt — og det er forskellen mellem anonym og pseudonym. Modtagerens side er den samme binary med en kontakt: `POST /api/v1/beacon` uden login, fordi den, der ringer, er en fremmed installation. Den svarer 404, når instansen ikke er tælleren, så en almindelig installation ikke er en spamspand, der svarer 200 på alt. Ratebegrænset som login-ruterne, og tabellen har et loft: endepunktet indsætter en række på et id, den, der ringer, har fundet på, og uden et loft kunne enhver fylde disken. Tallet er "set inden for tredive dage" og ikke et løbende total. Et total stiger kun og holder op med at betyde "hvor mange bruger det" og begynder at betyde "hvor mange har prøvet det". `internal/safedial` kom til undervejs: modtagerens adresse kan skrives i fladen, og en adresse, en bruger angiver, må ikke kunne pege indad. Kontrollen ligger på den opslåede adresse og ikke på URL'en, fordi et navn kan svare 127.0.0.1 — og svare noget andet anden gang, efter en kontrol ved parsning er gået godt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
9.8 KiB
Go
298 lines
9.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/beacon"
|
|
"github.com/kristianwind/verdande/internal/safedial"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// The beacon, from the instance's side.
|
|
//
|
|
// Two halves that happen to live in the same binary: the part that reports in,
|
|
// which every installation runs, and the part that receives, which only the one
|
|
// acting as collector turns on. See internal/beacon for what is sent and why it
|
|
// is on by default rather than compulsory.
|
|
|
|
const beaconScope = "beacon"
|
|
|
|
// beaconConfig is what this instance has been told to do. The zero value is what
|
|
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
|
|
// not here — a zero struct must never mean "silently on".
|
|
type beaconConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Collector string `json:"collector_url"`
|
|
InstanceID string `json:"instance_id"`
|
|
IsCollector bool `json:"is_collector"`
|
|
PublishCount bool `json:"publish_count"`
|
|
LastPingAt int64 `json:"last_ping_at,omitempty"`
|
|
}
|
|
|
|
// beaconSettings reads the configuration, filling in the defaults and minting the
|
|
// anonymous id on first use.
|
|
//
|
|
// The id is generated here rather than derived from anything about the machine.
|
|
// A hash of the hostname or the base URL would be stable and convenient and would
|
|
// also be a value the collector could compare against a guess — which is the
|
|
// difference between anonymous and pseudonymous, and it is the whole point.
|
|
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
|
|
cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
|
|
|
|
stored, err := s.db.InstanceSettings(ctx, beaconScope)
|
|
if err != nil {
|
|
return cfg, err
|
|
}
|
|
|
|
if v, ok := stored["enabled"].(bool); ok {
|
|
cfg.Enabled = v
|
|
}
|
|
if v, ok := stored["collector_url"].(string); ok && strings.TrimSpace(v) != "" {
|
|
cfg.Collector = strings.TrimSpace(v)
|
|
}
|
|
if v, ok := stored["instance_id"].(string); ok && beacon.ValidID(v) {
|
|
cfg.InstanceID = v
|
|
}
|
|
if v, ok := stored["is_collector"].(bool); ok {
|
|
cfg.IsCollector = v
|
|
}
|
|
if v, ok := stored["publish_count"].(bool); ok {
|
|
cfg.PublishCount = v
|
|
}
|
|
if v, ok := stored["last_ping_at"].(float64); ok {
|
|
cfg.LastPingAt = int64(v)
|
|
}
|
|
|
|
if cfg.InstanceID == "" {
|
|
cfg.InstanceID = store.NewID()
|
|
if err := s.saveBeaconSettings(ctx, cfg); err != nil {
|
|
return cfg, err
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error {
|
|
return s.db.SetInstanceSettings(ctx, beaconScope, map[string]any{
|
|
"enabled": cfg.Enabled,
|
|
"collector_url": cfg.Collector,
|
|
"instance_id": cfg.InstanceID,
|
|
"is_collector": cfg.IsCollector,
|
|
"publish_count": cfg.PublishCount,
|
|
"last_ping_at": cfg.LastPingAt,
|
|
})
|
|
}
|
|
|
|
// --- what the settings page shows ------------------------------------------------
|
|
|
|
type beaconStatusJSON struct {
|
|
Enabled bool `json:"enabled"`
|
|
Collector string `json:"collector_url"`
|
|
InstanceID string `json:"instance_id"`
|
|
Version string `json:"version"`
|
|
IsCollector bool `json:"is_collector"`
|
|
PublishCount bool `json:"publish_count"`
|
|
LastPingAt string `json:"last_ping_at,omitempty"`
|
|
Stats *store.BeaconStats `json:"stats,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := s.beaconSettings(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "beacon settings", err)
|
|
return
|
|
}
|
|
|
|
out := beaconStatusJSON{
|
|
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
|
|
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
|
|
}
|
|
if cfg.LastPingAt > 0 {
|
|
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
|
|
}
|
|
if cfg.IsCollector {
|
|
stats, err := s.db.BeaconSummary(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "beacon summary", err)
|
|
return
|
|
}
|
|
out.Stats = &stats
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type beaconRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Collector *string `json:"collector_url"`
|
|
IsCollector *bool `json:"is_collector"`
|
|
PublishCount *bool `json:"publish_count"`
|
|
}
|
|
|
|
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
|
|
var req beaconRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
cfg, err := s.beaconSettings(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "beacon settings", err)
|
|
return
|
|
}
|
|
|
|
if req.Enabled != nil {
|
|
cfg.Enabled = *req.Enabled
|
|
}
|
|
if req.IsCollector != nil {
|
|
cfg.IsCollector = *req.IsCollector
|
|
}
|
|
if req.PublishCount != nil {
|
|
cfg.PublishCount = *req.PublishCount
|
|
}
|
|
if req.Collector != nil {
|
|
url := strings.TrimSpace(*req.Collector)
|
|
if url == "" {
|
|
url = beacon.DefaultCollector
|
|
}
|
|
// http and https only, and said as a field error rather than a 500: an
|
|
// operator pointing this at their own collector will mistype it, and the
|
|
// page should say which field is wrong.
|
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
|
writeFieldErrors(w, map[string]string{"collector_url": "must start with http:// or https://"})
|
|
return
|
|
}
|
|
cfg.Collector = url
|
|
}
|
|
|
|
if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
|
|
s.internal(w, r, "save beacon settings", err)
|
|
return
|
|
}
|
|
s.handleBeaconStatus(w, r)
|
|
}
|
|
|
|
// --- the collector's side ---------------------------------------------------------
|
|
|
|
// handleBeaconPing receives one report. Unauthenticated, because the whole point
|
|
// is that a stranger's installation can reach it.
|
|
//
|
|
// Nothing about the request is recorded except the two values in the body. The
|
|
// source address is not read here, and there is no code path from this function
|
|
// to anything that could write one down — which is the only version of that
|
|
// promise worth making.
|
|
func (s *Server) handleBeaconPing(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := s.beaconSettings(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "beacon settings", err)
|
|
return
|
|
}
|
|
// An instance that is not a collector does not quietly accept and drop pings —
|
|
// it says there is nothing here. Otherwise every installation in the world is a
|
|
// spam sink that answers 200 to anything.
|
|
if !cfg.IsCollector {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "this instance is not a beacon collector")
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
|
|
var p beacon.Payload
|
|
if err := decodeJSON(w, r, &p); err != nil {
|
|
return
|
|
}
|
|
|
|
// Checked, not trusted. This is the one endpoint anybody can post to, and the
|
|
// two columns behind it should only ever hold the two shapes this program
|
|
// writes — an id that is not id-shaped is somebody probing, not an install.
|
|
if !beacon.ValidID(p.InstanceID) {
|
|
writeFieldErrors(w, map[string]string{"instance_id": "malformed"})
|
|
return
|
|
}
|
|
if p.Version != "" && !beacon.ValidVersion(p.Version) {
|
|
writeFieldErrors(w, map[string]string{"version": "malformed"})
|
|
return
|
|
}
|
|
|
|
if err := s.db.RecordBeacon(r.Context(), p.InstanceID, p.Version); err != nil {
|
|
s.internal(w, r, "record beacon", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleBeaconCount publishes the number, when the collector has been told to.
|
|
//
|
|
// Thirty days rather than a running total: a total only climbs, so it stops
|
|
// meaning "how many people use this" and starts meaning "how many ever tried it".
|
|
func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := s.beaconSettings(r.Context())
|
|
if err != nil {
|
|
s.internal(w, r, "beacon settings", err)
|
|
return
|
|
}
|
|
if !cfg.IsCollector || !cfg.PublishCount {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not published")
|
|
return
|
|
}
|
|
|
|
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
|
|
if err != nil {
|
|
s.internal(w, r, "beacon count", err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=60")
|
|
writeJSON(w, http.StatusOK, map[string]any{"installs": n, "window_days": 30})
|
|
}
|
|
|
|
// SendBeacon reports this installation, at most once a day.
|
|
//
|
|
// Exported and handed to the jobs runner the same way the mailbox syncs are: the
|
|
// settings, the anonymous id and the version all live on this side, and the
|
|
// runner should not have to know about any of them.
|
|
//
|
|
// The day is counted from the last successful send, not from a wall-clock hour.
|
|
// A fleet of installations that all pinged at 03:00 would arrive in one spike;
|
|
// counted this way they spread themselves out by when each was started.
|
|
func (s *Server) SendBeacon(ctx context.Context) error {
|
|
cfg, err := s.beaconSettings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
// The collector does not report to itself over the network. It would work, and
|
|
// it would also mean the one instance whose count matters most depends on its
|
|
// own tunnel being up to be counted.
|
|
if cfg.IsCollector {
|
|
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
|
|
return err
|
|
}
|
|
cfg.LastPingAt = time.Now().Unix()
|
|
return s.saveBeaconSettings(ctx, cfg)
|
|
}
|
|
|
|
if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
|
|
return nil
|
|
}
|
|
|
|
// safedial, ikke http.DefaultClient. Adressen kan skrives i fladen, og en
|
|
// administrator, der peger den på 127.0.0.1 eller på 169.254.169.254, ville
|
|
// ellers have en maskine, der henter indefra på kommando — og svaret på om den
|
|
// svarede, er i sig selv det, sådan et forsøg leder efter.
|
|
if err := beacon.Send(ctx, safedial.Client(15*time.Second), cfg.Collector, beacon.Payload{
|
|
InstanceID: cfg.InstanceID,
|
|
Version: Version,
|
|
}); err != nil {
|
|
// Logged at info, not error. The collector being unreachable is not this
|
|
// installation's problem and must never look like one — it is somebody
|
|
// else's server, and the only consequence is a number being one lower.
|
|
s.log.Info("beacon not delivered", "err", err)
|
|
return nil
|
|
}
|
|
|
|
cfg.LastPingAt = time.Now().Unix()
|
|
return s.saveBeaconSettings(ctx, cfg)
|
|
}
|