package mill import ( "github.com/gorilla/websocket" "io" "net/http" "slices" "strings" "sync" "time" millproto "tangled.org/core/spindle/mill/proto" millv1 "tangled.org/core/spindle/mill/proto/gen" ) var ( handshakeSem = make(chan struct{}, 16) handshakeMu sync.Mutex inFlightHandshakes = make(map[string]struct{}) ) type livenessReader struct { r io.Reader conn *websocket.Conn readTimeout time.Duration } func (lr *livenessReader) Read(p []byte) (int, error) { if err := lr.conn.SetReadDeadline(time.Now().Add(lr.readTimeout)); err != nil { return 0, err } return lr.r.Read(p) } var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } // auth before upgrade, a bad token never opens a socket func (m *Mill) HandleExecutorConn(w http.ResponseWriter, r *http.Request) { name, authorizedLabels, ok := m.authenticate(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } select { case handshakeSem <- struct{}{}: case <-r.Context().Done(): return } handshakeSlotHeld := true defer func() { if handshakeSlotHeld { <-handshakeSem } }() // enforces one in-flight handshake and one live session at a time per identity handshakeMu.Lock() if _, ok := inFlightHandshakes[name]; ok { handshakeMu.Unlock() http.Error(w, "handshake already in progress", http.StatusConflict) return } m.mu.Lock() old, exists := m.sessions[name] isLive := exists && old.live(m.cfg.ReconnectGrace) m.mu.Unlock() if isLive { handshakeMu.Unlock() http.Error(w, "session already active", http.StatusConflict) return } inFlightHandshakes[name] = struct{}{} handshakeMu.Unlock() identityHandshakeHeld := true defer func() { if identityHandshakeHeld { handshakeMu.Lock() delete(inFlightHandshakes, name) handshakeMu.Unlock() } }() conn, err := upgrader.Upgrade(w, r, nil) if err != nil { m.l.Error("fleet ws upgrade failed", "err", err) return } defer conn.Close() if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { m.l.Error("failed to set pre-hello read deadline", "err", err) return } stream := millproto.NewWSStream(conn) enc := millproto.NewEncoder(stream) dec := millproto.NewDecoder(stream) hello, err := dec.Decode() if err != nil { m.l.Error("fleet read hello failed", "err", err) return } h := hello.GetHello() if h == nil { m.l.Error("fleet first frame was not hello") return } if h.GetProtocolVersion() != millproto.ProtocolVersion { m.l.Error("fleet protocol version mismatch", "got", h.GetProtocolVersion(), "want", millproto.ProtocolVersion) return } if h.GetEpoch() == "" { m.l.Error("fleet hello missing epoch") return } for _, l := range h.GetLabels() { if !slices.Contains(authorizedLabels, l) { m.l.Error("executor requested unauthorized label", "label", l, "authorized", authorizedLabels) return } } sess := newSession(name, h.GetEpoch(), authorizedLabels, enc, m.l) sess.closeTransport = conn.Close sess.labels = h.GetLabels() resume, ok := m.attachSession(sess) if !ok { m.l.Warn("rejecting duplicate live executor session", "node", name) return } m.l.Info("executor connected", "node", sess.nodeID, "arch", h.GetArch(), "labels", h.GetLabels(), "resume", resume) if err := sess.send(&millproto.Message{Resume: &millv1.Resume{Epoch: h.GetEpoch(), AckSeqno: resume}}); err != nil { m.l.Error("fleet send resume failed", "err", err) m.detachSession(sess) return } handshakeSlotHeld = false <-handshakeSem handshakeMu.Lock() delete(inFlightHandshakes, name) handshakeMu.Unlock() identityHandshakeHeld = false m.sessionReady(sess) readTimeout := m.cfg.ReconnectGrace if readTimeout <= 0 { readTimeout = 45 * time.Second } liveDec := millproto.NewDecoder(&livenessReader{r: stream, conn: conn, readTimeout: readTimeout}) if err := sess.readLoop(m, liveDec); err != nil { m.l.Debug("session read ended", "node", sess.nodeID, "err", err) } m.detachSession(sess) } // identity comes from the token hash. unknown or missing token fails closed func (m *Mill) authenticate(r *http.Request) (string, []string, bool) { const prefix = "Bearer " h := r.Header.Get("Authorization") if !strings.HasPrefix(h, prefix) { return "", nil, false } token := strings.TrimPrefix(h, prefix) if token == "" || m.db == nil { return "", nil, false } name, labels, ok, err := m.db.ResolveExecutorToken(HashToken(token)) if err != nil { m.l.Error("executor token lookup failed", "err", err) return "", nil, false } return name, labels, ok }