websub-hub/cmd/hubserver/main.go
Peter Stuifzand 6fb4dc6ca8
Some checks failed
continuous-integration/drone Build is failing
Publish on hub.url, and answer with a status that means something
A publisher names the feed in hub.url (spec §7.1). This read hub.topic, which
belongs to subscribe and unsubscribe, so every compliant publisher sent a
notification the hub could not resolve: it logged "could not request topic :"
and returned 200 with an empty body. Nothing was ever delivered, and the
status said the opposite. hub.topic is still read, but only when hub.url is
missing.

The rest is the same class of bug — a response that reported success for work
that did not happen:

- a publish with no topic is 400, a topic that does not answer 2xx is 502,
  and a subscriber is told nothing about either;
- the fetched body is bounded, its response closed, and a callback is retried
  once with the outcome logged instead of assuming the first POST landed;
- unsubscribe wrote 200 and then an error body into the same response; it now
  answers once;
- the challenge comes from crypto/rand and must be echoed exactly, since a
  substring match let any page that mentioned it pass verification;
- pq.Error implements error on the pointer, so the value assertion never
  compiled; the statement now type-asserts the pointer;
- the database password can come from HUB_DB_PASSWORD instead of only being
  baked into the binary.

The build moves to go.mod and golang:1.22, because io.Discard does not exist
in the 1.12 image the pipeline was pinned to.
2026-11-08 01:20:53 +01:00

78 lines
2.1 KiB
Go

package main
import (
"crypto/rand"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
"p83.nl/go/websub-hub/cmd/hubserver/storage"
)
func init() {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
}
const challengeBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// randStringBytes is the hub's challenge. It comes from crypto/rand: a
// challenge a subscriber has to echo is a secret, and math/rand makes it
// predictable for anyone who observes a few of them.
func randStringBytes(n int) string {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
log.Fatalf("could not read random bytes: %v", err)
}
out := make([]byte, n)
for i, b := range buf {
out[i] = challengeBytes[int(b)%len(challengeBytes)]
}
return string(out)
}
type Stat struct {
Updates int
LastUpdate time.Time
}
func main() {
hostPort := flag.String("http", ":80", "host and port to listen on")
baseURL := flag.String("baseurl", "", "baseurl that the server should response with")
database := flag.String("database", "localhost:9999", "database host:port")
dbUser := flag.String("database-user", envOr("HUB_DB_USER", "postgres"), "database user")
dbPassword := flag.String("database-password", envOr("HUB_DB_PASSWORD", "simple"), "database password")
flag.Parse()
log.Printf("Using arguments http=%s baseurl=%s database=%s user=%s", *hostPort, *baseURL, *database, *dbUser)
if os.Getenv("HUB_DB_PASSWORD") == "" {
log.Printf("warning: HUB_DB_PASSWORD is unset, using the built-in default; set it in the deployment")
}
dsn := fmt.Sprintf("postgres://%s:%s@%s/?sslmode=disable", *dbUser, *dbPassword, *database)
store, err := storage.New(dsn)
if err != nil {
log.Fatal(err)
}
defer store.Close()
handler := &subscriptionHandler{
store: store,
baseURL: *baseURL,
// One client with a timeout: a topic or callback that hangs must not
// hold the hub forever.
client: &http.Client{Timeout: 30 * time.Second},
}
http.Handle("/", handler)
log.Fatal(http.ListenAndServe(*hostPort, handler))
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}