Generalise the registration pipeline to a more general auth pipeline supporting both node registrations and SSH check auth requests. Rename RegistrationID to AuthID, unexport AuthRequest fields, and introduce AuthVerdict to unify the auth finish API. Add the urlParam generic helper for extracting typed URL parameters from chi routes, used by the new auth request handler. Updates #1850
325 lines
8.8 KiB
Go
325 lines
8.8 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/assets"
|
|
"github.com/juanfont/headscale/hscontrol/templates"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/rs/zerolog/log"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
const (
|
|
// NoiseCapabilityVersion is used by Tailscale clients to indicate
|
|
// their codebase version. Tailscale clients can communicate over TS2021
|
|
// from CapabilityVersion 28, but we only have good support for it
|
|
// since https://github.com/tailscale/tailscale/pull/4323 (Noise in any HTTPS port).
|
|
//
|
|
// Related to this change, there is https://github.com/tailscale/tailscale/pull/5379,
|
|
// where CapabilityVersion 39 is introduced to indicate #4323 was merged.
|
|
//
|
|
// See also https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go
|
|
NoiseCapabilityVersion = 39
|
|
|
|
reservedResponseHeaderSize = 4
|
|
)
|
|
|
|
// httpError logs an error and sends an HTTP error response with the given.
|
|
func httpError(w http.ResponseWriter, err error) {
|
|
if herr, ok := errors.AsType[HTTPError](err); ok {
|
|
http.Error(w, herr.Msg, herr.Code)
|
|
log.Error().Err(herr.Err).Int("code", herr.Code).Msgf("user msg: %s", herr.Msg)
|
|
} else {
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
log.Error().Err(err).Int("code", http.StatusInternalServerError).Msg("http internal server error")
|
|
}
|
|
}
|
|
|
|
// HTTPError represents an error that is surfaced to the user via web.
|
|
type HTTPError struct {
|
|
Code int // HTTP response code to send to client; 0 means 500
|
|
Msg string // Response body to send to client
|
|
Err error // Detailed error to log on the server
|
|
}
|
|
|
|
func (e HTTPError) Error() string { return fmt.Sprintf("http error[%d]: %s, %s", e.Code, e.Msg, e.Err) }
|
|
func (e HTTPError) Unwrap() error { return e.Err }
|
|
|
|
// NewHTTPError returns an HTTPError containing the given information.
|
|
func NewHTTPError(code int, msg string, err error) HTTPError {
|
|
return HTTPError{Code: code, Msg: msg, Err: err}
|
|
}
|
|
|
|
var errMethodNotAllowed = NewHTTPError(http.StatusMethodNotAllowed, "method not allowed", nil)
|
|
|
|
var ErrRegisterMethodCLIDoesNotSupportExpire = errors.New(
|
|
"machines registered with CLI do not support expiry",
|
|
)
|
|
|
|
func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error) {
|
|
clientCapabilityStr := req.URL.Query().Get("v")
|
|
|
|
if clientCapabilityStr == "" {
|
|
return 0, NewHTTPError(http.StatusBadRequest, "capability version must be set", nil)
|
|
}
|
|
|
|
clientCapabilityVersion, err := strconv.Atoi(clientCapabilityStr)
|
|
if err != nil {
|
|
return 0, NewHTTPError(http.StatusBadRequest, "invalid capability version", fmt.Errorf("parsing capability version: %w", err))
|
|
}
|
|
|
|
return tailcfg.CapabilityVersion(clientCapabilityVersion), nil
|
|
}
|
|
|
|
func (h *Headscale) handleVerifyRequest(
|
|
req *http.Request,
|
|
writer io.Writer,
|
|
) error {
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading request body: %w", err)
|
|
}
|
|
|
|
var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest
|
|
if err := json.Unmarshal(body, &derpAdmitClientRequest); err != nil { //nolint:noinlineerr
|
|
return NewHTTPError(http.StatusBadRequest, "Bad Request: invalid JSON", fmt.Errorf("parsing DERP client request: %w", err))
|
|
}
|
|
|
|
nodes := h.state.ListNodes()
|
|
|
|
// Check if any node has the requested NodeKey
|
|
var nodeKeyFound bool
|
|
|
|
for _, node := range nodes.All() {
|
|
if node.NodeKey() == derpAdmitClientRequest.NodePublic {
|
|
nodeKeyFound = true
|
|
break
|
|
}
|
|
}
|
|
|
|
resp := &tailcfg.DERPAdmitClientResponse{
|
|
Allow: nodeKeyFound,
|
|
}
|
|
|
|
return json.NewEncoder(writer).Encode(resp)
|
|
}
|
|
|
|
// VerifyHandler see https://github.com/tailscale/tailscale/blob/964282d34f06ecc06ce644769c66b0b31d118340/derp/derp_server.go#L1159
|
|
// DERP use verifyClientsURL to verify whether a client is allowed to connect to the DERP server.
|
|
func (h *Headscale) VerifyHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
if req.Method != http.MethodPost {
|
|
httpError(writer, errMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
err := h.handleVerifyRequest(req, writer)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
}
|
|
|
|
// KeyHandler provides the Headscale pub key
|
|
// Listens in /key.
|
|
func (h *Headscale) KeyHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
// New Tailscale clients send a 'v' parameter to indicate the CurrentCapabilityVersion
|
|
capVer, err := parseCapabilityVersion(req)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
// TS2021 (Tailscale v2 protocol) requires to have a different key
|
|
if capVer >= NoiseCapabilityVersion {
|
|
resp := tailcfg.OverTLSPublicKeyResponse{
|
|
PublicKey: h.noisePrivateKey.Public(),
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
|
|
err := json.NewEncoder(writer).Encode(resp)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to encode public key response")
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *Headscale) HealthHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
respond := func(err error) {
|
|
writer.Header().Set("Content-Type", "application/health+json; charset=utf-8")
|
|
|
|
res := struct {
|
|
Status string `json:"status"`
|
|
}{
|
|
Status: "pass",
|
|
}
|
|
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
|
|
res.Status = "fail"
|
|
}
|
|
|
|
encErr := json.NewEncoder(writer).Encode(res)
|
|
if encErr != nil {
|
|
log.Error().Err(encErr).Msg("failed to encode health response")
|
|
}
|
|
}
|
|
|
|
err := h.state.PingDB(req.Context())
|
|
if err != nil {
|
|
respond(err)
|
|
|
|
return
|
|
}
|
|
|
|
respond(nil)
|
|
}
|
|
|
|
func (h *Headscale) RobotsHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
writer.Header().Set("Content-Type", "text/plain")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err := writer.Write([]byte("User-agent: *\nDisallow: /"))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write HTTP response")
|
|
}
|
|
}
|
|
|
|
// VersionHandler returns version information about the Headscale server
|
|
// Listens in /version.
|
|
func (h *Headscale) VersionHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
versionInfo := types.GetVersionInfo()
|
|
|
|
err := json.NewEncoder(writer).Encode(versionInfo)
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write version response")
|
|
}
|
|
}
|
|
|
|
type AuthProviderWeb struct {
|
|
serverURL string
|
|
}
|
|
|
|
func NewAuthProviderWeb(serverURL string) *AuthProviderWeb {
|
|
return &AuthProviderWeb{
|
|
serverURL: serverURL,
|
|
}
|
|
}
|
|
|
|
func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string {
|
|
return fmt.Sprintf(
|
|
"%s/register/%s",
|
|
strings.TrimSuffix(a.serverURL, "/"),
|
|
authID.String())
|
|
}
|
|
|
|
func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string {
|
|
return fmt.Sprintf(
|
|
"%s/auth/%s",
|
|
strings.TrimSuffix(a.serverURL, "/"),
|
|
authID.String())
|
|
}
|
|
|
|
func (a *AuthProviderWeb) AuthHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
}
|
|
|
|
func authIDFromRequest(req *http.Request) (types.AuthID, error) {
|
|
registrationId, err := urlParam[types.AuthID](req, "auth_id")
|
|
if err != nil {
|
|
return "", NewHTTPError(http.StatusBadRequest, "invalid registration id", fmt.Errorf("parsing auth_id from URL: %w", err))
|
|
}
|
|
|
|
// We need to make sure we dont open for XSS style injections, if the parameter that
|
|
// is passed as a key is not parsable/validated as a NodePublic key, then fail to render
|
|
// the template and log an error.
|
|
err = registrationId.Validate()
|
|
if err != nil {
|
|
return "", NewHTTPError(http.StatusBadRequest, "invalid registration id", fmt.Errorf("parsing auth_id from URL: %w", err))
|
|
}
|
|
|
|
return registrationId, nil
|
|
}
|
|
|
|
// RegisterHandler shows a simple message in the browser to point to the CLI
|
|
// Listens in /register/:registration_id.
|
|
//
|
|
// This is not part of the Tailscale control API, as we could send whatever URL
|
|
// in the RegisterResponse.AuthURL field.
|
|
func (a *AuthProviderWeb) RegisterHandler(
|
|
writer http.ResponseWriter,
|
|
req *http.Request,
|
|
) {
|
|
registrationId, err := authIDFromRequest(req)
|
|
if err != nil {
|
|
httpError(writer, err)
|
|
return
|
|
}
|
|
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err = writer.Write([]byte(templates.RegisterWeb(registrationId).Render()))
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to write register response")
|
|
}
|
|
}
|
|
|
|
func FaviconHandler(writer http.ResponseWriter, req *http.Request) {
|
|
writer.Header().Set("Content-Type", "image/png")
|
|
http.ServeContent(writer, req, "favicon.ico", time.Unix(0, 0), bytes.NewReader(assets.Favicon))
|
|
}
|
|
|
|
// BlankHandler returns a blank page with favicon linked.
|
|
func BlankHandler(writer http.ResponseWriter, res *http.Request) {
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(http.StatusOK)
|
|
|
|
_, err := writer.Write([]byte(templates.BlankPage().Render()))
|
|
if err != nil {
|
|
log.Error().
|
|
Caller().
|
|
Err(err).
|
|
Msg("Failed to write HTTP response")
|
|
}
|
|
}
|