150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
|
// Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved.
|
||
|
// Use of this source code is governed by a BSD-style
|
||
|
// license that can be found in the LICENSE file.
|
||
|
|
||
|
// Package webhooks provides example consumer code for Tailscale
|
||
|
// webhooks.
|
||
|
package webhooks
|
||
|
|
||
|
import (
|
||
|
"crypto/hmac"
|
||
|
"crypto/sha256"
|
||
|
"encoding/hex"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
)
|
||
|
|
||
|
type event struct {
|
||
|
Timestamp string `json:"timestamp"`
|
||
|
Version int `json:"version"`
|
||
|
Type string `json:"type"`
|
||
|
Tailnet string `json:"tailnet"`
|
||
|
Message string `json:"message"`
|
||
|
Data map[string]string `json:"data"`
|
||
|
}
|
||
|
|
||
|
const (
|
||
|
currentVersion = "v1"
|
||
|
secret = "tskey-webhook-xxxxx" // sensitive, here just as an example
|
||
|
)
|
||
|
|
||
|
var (
|
||
|
errNotSigned = errors.New("webhook has no signature")
|
||
|
errInvalidHeader = errors.New("webhook has an invalid signature")
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
http.HandleFunc("/webhook", webhooksHandler)
|
||
|
if err := http.ListenAndServe(":80", nil); err != nil {
|
||
|
log.Fatal(err)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func webhooksHandler(w http.ResponseWriter, req *http.Request) {
|
||
|
defer req.Body.Close()
|
||
|
events, err := verifyWebhookSignature(req, secret)
|
||
|
if err != nil {
|
||
|
log.Printf("error validating signature: %v\n", err)
|
||
|
} else {
|
||
|
log.Printf("events received %v\n", events)
|
||
|
// Do something with your events. :)
|
||
|
}
|
||
|
|
||
|
// The handler should always report 2XX except in the case of
|
||
|
// transient failures (e.g. database backend is down).
|
||
|
// Otherwise your future events will be blocked by retries.
|
||
|
}
|
||
|
|
||
|
// verifyWebhookSignature checks the request's "Tailscale-Webhook-Signature"
|
||
|
// header to verify that the events were signed by your webhook secret.
|
||
|
// If verification fails, an error is reported.
|
||
|
// If verification succeeds, the list of contained events is reported.
|
||
|
func verifyWebhookSignature(req *http.Request, secret string) (events []event, err error) {
|
||
|
defer req.Body.Close()
|
||
|
|
||
|
// Grab the signature sent on the request header.
|
||
|
timestamp, signatures, err := parseSignatureHeader(req.Header.Get("Tailscale-Webhook-Signature"))
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
// Verify that the timestamp is recent.
|
||
|
// Here, we use a threshold of 5 minutes.
|
||
|
if timestamp.Before(time.Now().Add(-time.Minute * 5)) {
|
||
|
return nil, fmt.Errorf("invalid header: timestamp older than 5 minutes")
|
||
|
}
|
||
|
|
||
|
// Form the expected signature.
|
||
|
b, err := io.ReadAll(req.Body)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||
|
mac.Write([]byte(fmt.Sprint(timestamp.Unix())))
|
||
|
mac.Write([]byte("."))
|
||
|
mac.Write(b)
|
||
|
want := hex.EncodeToString(mac.Sum(nil))
|
||
|
|
||
|
// Verify that the signatures match.
|
||
|
var match bool
|
||
|
for _, signature := range signatures[currentVersion] {
|
||
|
if signature == want {
|
||
|
match = true
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
if !match {
|
||
|
return nil, fmt.Errorf("signature does not match: want = %q, got = %q", want, signatures[currentVersion])
|
||
|
}
|
||
|
|
||
|
// If verified, return the events.
|
||
|
if err := json.Unmarshal(b, &events); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
return events, nil
|
||
|
}
|
||
|
|
||
|
// parseSignatureHeader splits header into its timestamp and included signatures.
|
||
|
// The signatures are reported as a map of version (e.g. "v1") to a list of signatures
|
||
|
// found with that version.
|
||
|
func parseSignatureHeader(header string) (timestamp time.Time, signatures map[string][]string, err error) {
|
||
|
if header == "" {
|
||
|
return time.Time{}, nil, fmt.Errorf("request has no signature")
|
||
|
}
|
||
|
|
||
|
signatures = make(map[string][]string)
|
||
|
pairs := strings.Split(header, ",")
|
||
|
for _, pair := range pairs {
|
||
|
parts := strings.Split(pair, "=")
|
||
|
if len(parts) != 2 {
|
||
|
return time.Time{}, nil, errNotSigned
|
||
|
}
|
||
|
|
||
|
switch parts[0] {
|
||
|
case "t":
|
||
|
tsint, err := strconv.ParseInt(parts[1], 10, 64)
|
||
|
if err != nil {
|
||
|
return time.Time{}, nil, errInvalidHeader
|
||
|
}
|
||
|
timestamp = time.Unix(tsint, 0)
|
||
|
case currentVersion:
|
||
|
signatures[parts[0]] = append(signatures[parts[0]], parts[1])
|
||
|
default:
|
||
|
// Ignore unknown parts of the header.
|
||
|
continue
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if len(signatures) == 0 {
|
||
|
return time.Time{}, nil, errNotSigned
|
||
|
}
|
||
|
return
|
||
|
}
|