mirror of
https://github.com/juanfont/headscale.git
synced 2024-12-19 06:27:35 +00:00
1e61084898
* handle control protocol through websocket The necessary behaviour is already in place, but the wasm build only issued GETs, and the handler was not invoked. * get DERP-over-websocket working for wasm clients * Prepare for testing builtin websocket-over-DERP Still needs some way to assert that clients are connected through websockets, rather than the TCP hijacking version of DERP. * integration tests: properly differentiate between DERP transports * do not touch unrelated code * linter fixes * integration testing: unexport common implementation of derp server scenario * fixup! integration testing: unexport common implementation of derp server scenario * dockertestutil/logs: remove unhelpful comment * update changelog --------- Co-authored-by: Csaba Sarkadi <sarkadicsa@tutanota.de>
363 lines
9.7 KiB
Go
363 lines
9.7 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/rs/zerolog/log"
|
|
"tailscale.com/derp"
|
|
"tailscale.com/net/stun"
|
|
"tailscale.com/net/wsconn"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// fastStartHeader is the header (with value "1") that signals to the HTTP
|
|
// server that the DERP HTTP client does not want the HTTP 101 response
|
|
// headers and it will begin writing & reading the DERP protocol immediately
|
|
// following its HTTP request.
|
|
const fastStartHeader = "Derp-Fast-Start"
|
|
|
|
type DERPServer struct {
|
|
serverURL string
|
|
key key.NodePrivate
|
|
cfg *types.DERPConfig
|
|
tailscaleDERP *derp.Server
|
|
}
|
|
|
|
func NewDERPServer(
|
|
serverURL string,
|
|
derpKey key.NodePrivate,
|
|
cfg *types.DERPConfig,
|
|
) (*DERPServer, error) {
|
|
log.Trace().Caller().Msg("Creating new embedded DERP server")
|
|
server := derp.NewServer(derpKey, util.TSLogfWrapper()) // nolint // zerolinter complains
|
|
|
|
return &DERPServer{
|
|
serverURL: serverURL,
|
|
key: derpKey,
|
|
cfg: cfg,
|
|
tailscaleDERP: server,
|
|
}, nil
|
|
}
|
|
|
|
func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) {
|
|
serverURL, err := url.Parse(d.serverURL)
|
|
if err != nil {
|
|
return tailcfg.DERPRegion{}, err
|
|
}
|
|
var host string
|
|
var port int
|
|
host, portStr, err := net.SplitHostPort(serverURL.Host)
|
|
if err != nil {
|
|
if serverURL.Scheme == "https" {
|
|
host = serverURL.Host
|
|
port = 443
|
|
} else {
|
|
host = serverURL.Host
|
|
port = 80
|
|
}
|
|
} else {
|
|
port, err = strconv.Atoi(portStr)
|
|
if err != nil {
|
|
return tailcfg.DERPRegion{}, err
|
|
}
|
|
}
|
|
|
|
localDERPregion := tailcfg.DERPRegion{
|
|
RegionID: d.cfg.ServerRegionID,
|
|
RegionCode: d.cfg.ServerRegionCode,
|
|
RegionName: d.cfg.ServerRegionName,
|
|
Avoid: false,
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
Name: fmt.Sprintf("%d", d.cfg.ServerRegionID),
|
|
RegionID: d.cfg.ServerRegionID,
|
|
HostName: host,
|
|
DERPPort: port,
|
|
IPv4: d.cfg.IPv4,
|
|
IPv6: d.cfg.IPv6,
|
|
},
|
|
},
|
|
}
|
|
|
|
_, portSTUNStr, err := net.SplitHostPort(d.cfg.STUNAddr)
|
|
if err != nil {
|
|
return tailcfg.DERPRegion{}, err
|
|
}
|
|
portSTUN, err := strconv.Atoi(portSTUNStr)
|
|
if err != nil {
|
|
return tailcfg.DERPRegion{}, err
|
|
}
|
|
localDERPregion.Nodes[0].STUNPort = portSTUN
|
|
|
|
log.Info().Caller().Msgf("DERP region: %+v", localDERPregion)
|
|
log.Info().Caller().Msgf("DERP Nodes[0]: %+v", localDERPregion.Nodes[0])
|
|
|
|
return localDERPregion, nil
|
|
}
|
|
|
|
func (d *DERPServer) DERPHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
log.Trace().Caller().Msgf("/derp request from %v", req.RemoteAddr)
|
|
upgrade := strings.ToLower(req.Header.Get("Upgrade"))
|
|
|
|
if upgrade != "websocket" && upgrade != "derp" {
|
|
if upgrade != "" {
|
|
log.Warn().
|
|
Caller().
|
|
Msg("No Upgrade header in DERP server request. If headscale is behind a reverse proxy, make sure it is configured to pass WebSockets through.")
|
|
}
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusUpgradeRequired)
|
|
_, err := writer.Write([]byte("DERP requires connection upgrade"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if strings.Contains(req.Header.Get("Sec-Websocket-Protocol"), "derp") {
|
|
d.serveWebsocket(writer, req)
|
|
} else {
|
|
d.servePlain(writer, req)
|
|
}
|
|
}
|
|
|
|
func (d *DERPServer) serveWebsocket(writer http.ResponseWriter, req *http.Request) {
|
|
websocketConn, err := websocket.Accept(writer, req, &websocket.AcceptOptions{
|
|
Subprotocols: []string{"derp"},
|
|
OriginPatterns: []string{"*"},
|
|
// Disable compression because DERP transmits WireGuard messages that
|
|
// are not compressible.
|
|
// Additionally, Safari has a broken implementation of compression
|
|
// (see https://github.com/nhooyr/websocket/issues/218) that makes
|
|
// enabling it actively harmful.
|
|
CompressionMode: websocket.CompressionDisabled,
|
|
})
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to upgrade websocket request")
|
|
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
|
|
_, err = writer.Write([]byte("Failed to upgrade websocket request"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
|
|
return
|
|
}
|
|
defer websocketConn.Close(websocket.StatusInternalError, "closing")
|
|
if websocketConn.Subprotocol() != "derp" {
|
|
websocketConn.Close(websocket.StatusPolicyViolation, "client must speak the derp subprotocol")
|
|
|
|
return
|
|
}
|
|
|
|
wc := wsconn.NetConn(req.Context(), websocketConn, websocket.MessageBinary, req.RemoteAddr)
|
|
brw := bufio.NewReadWriter(bufio.NewReader(wc), bufio.NewWriter(wc))
|
|
d.tailscaleDERP.Accept(req.Context(), wc, brw, req.RemoteAddr)
|
|
}
|
|
|
|
func (d *DERPServer) servePlain(writer http.ResponseWriter, req *http.Request) {
|
|
fastStart := req.Header.Get(fastStartHeader) == "1"
|
|
|
|
hijacker, ok := writer.(http.Hijacker)
|
|
if !ok {
|
|
log.Error().Caller().Msg("DERP requires Hijacker interface from Gin")
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
_, err := writer.Write([]byte("HTTP does not support general TCP support"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
netConn, conn, err := hijacker.Hijack()
|
|
if err != nil {
|
|
log.Error().Caller().Err(err).Msgf("Hijack failed")
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
_, err = writer.Write([]byte("HTTP does not support general TCP support"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
|
|
return
|
|
}
|
|
log.Trace().Caller().Msgf("Hijacked connection from %v", req.RemoteAddr)
|
|
|
|
if !fastStart {
|
|
pubKey := d.key.Public()
|
|
pubKeyStr, _ := pubKey.MarshalText() //nolint
|
|
fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
|
|
"Upgrade: DERP\r\n"+
|
|
"Connection: Upgrade\r\n"+
|
|
"Derp-Version: %v\r\n"+
|
|
"Derp-Public-Key: %s\r\n\r\n",
|
|
derp.ProtocolVersion,
|
|
string(pubKeyStr))
|
|
}
|
|
|
|
d.tailscaleDERP.Accept(req.Context(), netConn, conn, netConn.RemoteAddr().String())
|
|
}
|
|
|
|
// DERPProbeHandler is the endpoint that js/wasm clients hit to measure
|
|
// DERP latency, since they can't do UDP STUN queries.
|
|
func DERPProbeHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
switch req.Method {
|
|
case http.MethodHead, http.MethodGet:
|
|
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
writer.WriteHeader(http.StatusOK)
|
|
default:
|
|
writer.WriteHeader(http.StatusMethodNotAllowed)
|
|
_, err := writer.Write([]byte("bogus probe method"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
}
|
|
}
|
|
|
|
// DERPBootstrapDNSHandler implements the /bootstrap-dns endpoint
|
|
// Described in https://github.com/tailscale/tailscale/issues/1405,
|
|
// this endpoint provides a way to help a client when it fails to start up
|
|
// because its DNS are broken.
|
|
// The initial implementation is here https://github.com/tailscale/tailscale/pull/1406
|
|
// They have a cache, but not clear if that is really necessary at Headscale, uh, scale.
|
|
// An example implementation is found here https://derp.tailscale.com/bootstrap-dns
|
|
// Coordination server is included automatically, since local DERP is using the same DNS Name in d.serverURL.
|
|
func DERPBootstrapDNSHandler(
|
|
derpMap *tailcfg.DERPMap,
|
|
) func(http.ResponseWriter, *http.Request) {
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
dnsEntries := make(map[string][]net.IP)
|
|
|
|
resolvCtx, cancel := context.WithTimeout(req.Context(), time.Minute)
|
|
defer cancel()
|
|
var resolver net.Resolver
|
|
for _, region := range derpMap.Regions {
|
|
for _, node := range region.Nodes { // we don't care if we override some nodes
|
|
addrs, err := resolver.LookupIP(resolvCtx, "ip", node.HostName)
|
|
if err != nil {
|
|
log.Trace().
|
|
Caller().
|
|
Err(err).
|
|
Msgf("bootstrap DNS lookup failed %q", node.HostName)
|
|
|
|
continue
|
|
}
|
|
dnsEntries[node.HostName] = addrs
|
|
}
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(http.StatusOK)
|
|
err := json.NewEncoder(writer).Encode(dnsEntries)
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write response")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ServeSTUN starts a STUN server on the configured addr.
|
|
func (d *DERPServer) ServeSTUN() {
|
|
packetConn, err := net.ListenPacket("udp", d.cfg.STUNAddr)
|
|
if err != nil {
|
|
log.Fatal().Msgf("failed to open STUN listener: %v", err)
|
|
}
|
|
log.Info().Msgf("STUN server started at %s", packetConn.LocalAddr())
|
|
|
|
udpConn, ok := packetConn.(*net.UDPConn)
|
|
if !ok {
|
|
log.Fatal().Msg("STUN listener is not a UDP listener")
|
|
}
|
|
serverSTUNListener(context.Background(), udpConn)
|
|
}
|
|
|
|
func serverSTUNListener(ctx context.Context, packetConn *net.UDPConn) {
|
|
var buf [64 << 10]byte
|
|
var (
|
|
bytesRead int
|
|
udpAddr *net.UDPAddr
|
|
err error
|
|
)
|
|
for {
|
|
bytesRead, udpAddr, err = packetConn.ReadFromUDP(buf[:])
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
log.Error().Caller().Err(err).Msgf("STUN ReadFrom")
|
|
time.Sleep(time.Second)
|
|
|
|
continue
|
|
}
|
|
log.Trace().Caller().Msgf("STUN request from %v", udpAddr)
|
|
pkt := buf[:bytesRead]
|
|
if !stun.Is(pkt) {
|
|
log.Trace().Caller().Msgf("UDP packet is not STUN")
|
|
|
|
continue
|
|
}
|
|
txid, err := stun.ParseBindingRequest(pkt)
|
|
if err != nil {
|
|
log.Trace().Caller().Err(err).Msgf("STUN parse error")
|
|
|
|
continue
|
|
}
|
|
|
|
addr, _ := netip.AddrFromSlice(udpAddr.IP)
|
|
res := stun.Response(txid, netip.AddrPortFrom(addr, uint16(udpAddr.Port)))
|
|
_, err = packetConn.WriteTo(res, udpAddr)
|
|
if err != nil {
|
|
log.Trace().Caller().Err(err).Msgf("Issue writing to UDP")
|
|
|
|
continue
|
|
}
|
|
}
|
|
}
|