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>
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
// Package safedial refuses to open a connection to this machine or to the
|
|
// network it sits on.
|
|
//
|
|
// The problem it solves is the same one three times: somewhere a user supplies a
|
|
// URL or a host, and the server fetches it. An AI provider's base address, a push
|
|
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
|
|
// of them is also a way to ask the server to reach something the caller cannot —
|
|
// the panel next door, a metadata endpoint, a database on the same bridge — and
|
|
// to report back whether it answered. On a homelab that is the whole point of the
|
|
// box being where it is.
|
|
//
|
|
// # Why the check is on the address and not on the URL
|
|
//
|
|
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
|
|
// and it does not work: a name resolves to whatever its owner says, so
|
|
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
|
|
// time, after a parse-time check has passed. That is DNS rebinding, and the only
|
|
// place it can be caught is after resolution and before the connection, which is
|
|
// exactly where net.Dialer's Control hook runs.
|
|
//
|
|
// # What is refused
|
|
//
|
|
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
|
|
// lives), the private ranges, unique-local IPv6, and the unspecified address.
|
|
// Everything else is allowed: this is not an allowlist of the internet, it is a
|
|
// wall around the inside.
|
|
package safedial
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ErrPrivate is returned when a connection was refused for where it pointed. It
|
|
// deliberately does not say which address it resolved to: the answer is itself
|
|
// the thing an attacker is fishing for.
|
|
type ErrPrivate struct{ Host string }
|
|
|
|
func (e *ErrPrivate) Error() string {
|
|
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
|
|
}
|
|
|
|
// Blocked reports whether an address must not be dialled.
|
|
func Blocked(ip net.IP) bool {
|
|
return ip == nil ||
|
|
ip.IsLoopback() ||
|
|
ip.IsUnspecified() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() ||
|
|
ip.IsInterfaceLocalMulticast() ||
|
|
ip.IsMulticast() ||
|
|
ip.IsPrivate()
|
|
}
|
|
|
|
// Control is a net.Dialer Control function that refuses a private destination.
|
|
//
|
|
// It runs after the name has been resolved and before the socket is connected,
|
|
// which is the only moment where the address that will actually be used is known.
|
|
func Control(network, address string, _ syscall.RawConn) error {
|
|
host, _, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if Blocked(ip) {
|
|
return &ErrPrivate{Host: host}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Dialer is a net.Dialer that will not reach inside.
|
|
func Dialer(timeout time.Duration) *net.Dialer {
|
|
return &net.Dialer{Timeout: timeout, Control: Control}
|
|
}
|
|
|
|
// Client is an http.Client for fetching something a user named.
|
|
//
|
|
// Redirects are followed but capped, and every hop goes through the same dialer —
|
|
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
|
|
// URL somebody typed.
|
|
func Client(timeout time.Duration) *http.Client {
|
|
return &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{
|
|
DialContext: Dialer(timeout).DialContext,
|
|
TLSHandshakeTimeout: timeout,
|
|
ResponseHeaderTimeout: timeout,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 5 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// AllowPrivate is the same client without the wall, for a destination the
|
|
// operator configured rather than a user — the panel, and nothing else. Named so
|
|
// that using it is a decision somebody wrote down.
|
|
func AllowPrivate(timeout time.Duration) *http.Client {
|
|
return &http.Client{Timeout: timeout}
|
|
}
|