sueta / server / main.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// sueta-signal: blind WebSocket signaling relay + coturn REST credential minter.
//
// The server routes opaque encrypted blobs between peers that share a roomId.
// It never parses, stores, or logs payloads. See docs/PROTOCOL.md.
package main

import (
	"crypto/tls"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"golang.org/x/crypto/acme/autocert"
)

type Config struct {
	Hosts         []string // hostnames autocert may issue certs for (ALLOWED_HOSTS)
	Origins       []string // allowed WS Origin values; empty or "*" = any (ORIGINS)
	AuthSecret    string   // coturn static-auth-secret (STATIC_AUTH_SECRET)
	TurnURLs      []string // ICE server URLs handed to clients (TURN_URLS)
	CertDir       string   // autocert cache dir
	MaxRooms      int
	MaxRoomPeers  int
	MaxConnsPerIP int
}

func loadConfig() *Config {
	cfg := &Config{
		Hosts:         splitCSV(os.Getenv("ALLOWED_HOSTS")),
		Origins:       splitCSV(os.Getenv("ORIGINS")),
		AuthSecret:    os.Getenv("STATIC_AUTH_SECRET"),
		TurnURLs:      splitCSV(os.Getenv("TURN_URLS")),
		CertDir:       os.Getenv("CERT_DIR"),
		MaxRooms:      500,
		MaxRoomPeers:  16,
		MaxConnsPerIP: 20,
	}
	if len(cfg.TurnURLs) == 0 {
		cfg.TurnURLs = []string{
			"stun:stun.ardegazu.ro:3478",
			"turn:stun.ardegazu.ro:3478?transport=udp",
			"turn:stun.ardegazu.ro:3478?transport=tcp",
		}
	}
	if cfg.CertDir == "" {
		// systemd StateDirectory=sueta-signal
		if sd := os.Getenv("STATE_DIRECTORY"); sd != "" {
			cfg.CertDir = sd + "/certs"
		} else {
			cfg.CertDir = "certs"
		}
	}
	return cfg
}

func splitCSV(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// originAllowed matches r's Origin against cfg.Origins. Entries may be exact
// ("https://chat.ardegazu.ro"), wildcard-subdomain ("*.ardegazu.ro" matches any
// scheme+subdomain of ardegazu.ro), or "*" (any). Empty list = any.
func (cfg *Config) originAllowed(origin string) bool {
	if len(cfg.Origins) == 0 {
		return true
	}
	host := origin
	if i := strings.Index(host, "://"); i >= 0 {
		host = host[i+3:]
	}
	host = strings.Split(host, ":")[0]
	for _, o := range cfg.Origins {
		switch {
		case o == "*":
			return true
		case strings.HasPrefix(o, "*."):
			base := o[1:] // ".ardegazu.ro"
			if strings.HasSuffix(host, base) || host == o[2:] {
				return true
			}
		case o == origin:
			return true
		}
	}
	return false
}

func newMux(cfg *Config, hub *Hub) *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("/ws", hub.handleWS)
	mux.HandleFunc("/turn-credentials", turnCredsHandler(cfg))
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		rooms, peers := hub.stats()
		fmt.Fprintf(w, "ok rooms=%d peers=%d\n", rooms, peers)
	})
	return mux
}

func main() {
	dev := flag.Bool("dev", false, "plain HTTP, no TLS (local development)")
	addr := flag.String("addr", ":8080", "listen address in --dev mode")
	flag.Parse()

	cfg := loadConfig()
	hub := newHub(cfg)
	mux := newMux(cfg, hub)

	if *dev {
		log.Printf("sueta-signal --dev listening on %s", *addr)
		log.Fatal(http.ListenAndServe(*addr, mux))
	}

	if len(cfg.Hosts) == 0 {
		log.Fatal("ALLOWED_HOSTS must be set (comma-separated hostnames for TLS certs)")
	}
	m := &autocert.Manager{
		Prompt:     autocert.AcceptTOS,
		HostPolicy: autocert.HostWhitelist(cfg.Hosts...),
		Cache:      autocert.DirCache(cfg.CertDir),
	}
	go func() {
		// :80 serves ACME http-01 challenges and redirects everything else to https.
		log.Fatal(http.ListenAndServe(":80", m.HTTPHandler(nil)))
	}()
	srv := &http.Server{
		Addr:      ":443",
		Handler:   mux,
		TLSConfig: &tls.Config{GetCertificate: m.GetCertificate, MinVersion: tls.VersionTLS12},
		// WS connections are long-lived; only bound the handshake phases.
		ReadHeaderTimeout: 10 * time.Second,
	}
	log.Printf("sueta-signal listening on :443 for %v", cfg.Hosts)
	log.Fatal(srv.ListenAndServeTLS("", ""))
}