parent
95f3dd1346
commit
944f43f1c8
@ -0,0 +1,149 @@ |
||||
// 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 |
||||
} |
||||
Loading…
Reference in new issue