diff --git a/derp/derp.go b/derp/derp.go index 2774e2b3d..37bb8e604 100644 --- a/derp/derp.go +++ b/derp/derp.go @@ -101,6 +101,20 @@ const ( framePing = frameType(0x12) // 8 byte ping payload, to be echoed back in framePong framePong = frameType(0x13) // 8 byte payload, the contents of the ping being replied to + + // frameHealth is sent from server to client to tell the client + // if their connection is unhealthy somehow. Currently the only unhealthy state + // is whether the connection is detected as a duplicate. + // The entire frame body is the text of the error message. An empty message + // clears the error state. + frameHealth = frameType(0x14) + + // frameRestarting is sent from server to client for the + // server to declare that it's restarting. Payload is two big + // endian uint32 durations in milliseconds: when to reconnect, + // and how long to try total. See ServerRestartingMessage docs for + // more details on how the client should interpret them. + frameRestarting = frameType(0x15) ) var bin = binary.BigEndian diff --git a/derp/derp_client.go b/derp/derp_client.go index ba258cf2b..c584fd554 100644 --- a/derp/derp_client.go +++ b/derp/derp_client.go @@ -7,6 +7,7 @@ package derp import ( "bufio" crand "crypto/rand" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -369,6 +370,40 @@ type KeepAliveMessage struct{} func (KeepAliveMessage) msg() {} +// HealthMessage is a one-way message from server to client, declaring the +// connection health state. +type HealthMessage struct { + // Problem, if non-empty, is a description of why the connection + // is unhealthy. + // + // The empty string means the connection is healthy again. + // + // The default condition is healthy, so the server doesn't + // broadcast a HealthMessage until a problem exists. + Problem string +} + +func (HealthMessage) msg() {} + +// ServerRestartingMessage is a one-way message from server to client, +// advertising that the server is restarting. +type ServerRestartingMessage struct { + // ReconnectIn is an advisory duration that the client should wait + // before attempting to reconnect. It might be zero. + // It exists for the server to smear out the reconnects. + ReconnectIn time.Duration + + // TryFor is an advisory duration for how long the client + // should attempt to reconnect before giving up and proceeding + // with its normal connection failure logic. The interval + // between retries is undefined for now. + // A server should not send a TryFor duration more than a few + // seconds. + TryFor time.Duration +} + +func (ServerRestartingMessage) msg() {} + // Recv reads a message from the DERP server. // // The returned message may alias memory owned by the Client; it @@ -486,6 +521,19 @@ func (c *Client) recvTimeout(timeout time.Duration) (m ReceivedMessage, err erro } copy(pm[:], b[:]) return pm, nil + + case frameHealth: + return HealthMessage{Problem: string(b[:])}, nil + + case frameRestarting: + var m ServerRestartingMessage + if n < 8 { + c.logf("[unexpected] dropping short server restarting frame") + continue + } + m.ReconnectIn = time.Duration(binary.BigEndian.Uint32(b[0:4])) * time.Millisecond + m.TryFor = time.Duration(binary.BigEndian.Uint32(b[4:8])) * time.Millisecond + return m, nil } } } diff --git a/derp/derp_test.go b/derp/derp_test.go index 5521fdc09..c94c36217 100644 --- a/derp/derp_test.go +++ b/derp/derp_test.go @@ -814,6 +814,33 @@ func TestClientRecv(t *testing.T) { }, want: PingMessage{1, 2, 3, 4, 5, 6, 7, 8}, }, + { + name: "health_bad", + input: []byte{ + byte(frameHealth), 0, 0, 0, 3, + byte('B'), byte('A'), byte('D'), + }, + want: HealthMessage{Problem: "BAD"}, + }, + { + name: "health_ok", + input: []byte{ + byte(frameHealth), 0, 0, 0, 0, + }, + want: HealthMessage{}, + }, + { + name: "server_restarting", + input: []byte{ + byte(frameRestarting), 0, 0, 0, 8, + 0, 0, 0, 1, + 0, 0, 0, 2, + }, + want: ServerRestartingMessage{ + ReconnectIn: 1 * time.Millisecond, + TryFor: 2 * time.Millisecond, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {