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)
|
||
|
|
}
|