Compare commits
5 Commits
clairew/re
...
dsnet/http
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e20bd2ffe | ||
|
|
b89c113365 | ||
|
|
ff9c1ebb4a | ||
|
|
5cc1bfe82d | ||
|
|
469af614b0 |
@@ -95,9 +95,16 @@ function LoginPopoverContent({
|
||||
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
|
||||
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
|
||||
|
||||
// Whether the current page is loaded over HTTPS.
|
||||
// If it is, then the connectivity check to the management client
|
||||
// will fail with a mixed-content error.
|
||||
const isHTTPS = window.location.protocol === "https:"
|
||||
|
||||
const checkTSConnection = useCallback(() => {
|
||||
if (auth.viewerIdentity) {
|
||||
setCanConnectOverTS(true) // already connected over ts
|
||||
if (auth.viewerIdentity || isHTTPS) {
|
||||
// Skip the connectivity check if we either already know we're connected over Tailscale,
|
||||
// or know the connectivity check will fail because the current page is loaded over HTTPS.
|
||||
setCanConnectOverTS(true)
|
||||
return
|
||||
}
|
||||
// Otherwise, test connection to the ts IP.
|
||||
@@ -111,7 +118,7 @@ function LoginPopoverContent({
|
||||
setIsRunningCheck(false)
|
||||
})
|
||||
.catch(() => setIsRunningCheck(false))
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4])
|
||||
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
|
||||
|
||||
/**
|
||||
* Checking connection for first time on page load.
|
||||
@@ -193,6 +200,14 @@ function LoginPopoverContent({
|
||||
You can see most of this device's details. To make changes,
|
||||
you need to sign in.
|
||||
</p>
|
||||
{isHTTPS && (
|
||||
// we don't know if the user can connect over TS, so
|
||||
// provide extra tips in case they have trouble.
|
||||
<p className="text-gray-500 text-xs font-semibold pt-2">
|
||||
Make sure you are connected to your tailnet, and that your
|
||||
policy file allows access.
|
||||
</p>
|
||||
)}
|
||||
<SignInButton auth={auth} onClick={handleSignInClick} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -59,8 +59,6 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "{{ .Values.enableConnector }}"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
||||
@@ -8,10 +8,6 @@ oauth: {}
|
||||
# clientId: ""
|
||||
# clientSecret: ""
|
||||
|
||||
# enableConnector determines whether the operator should reconcile
|
||||
# connector.tailscale.com custom resources.
|
||||
enableConnector: "false"
|
||||
|
||||
# installCRDs determines whether tailscale.com CRDs should be installed as part
|
||||
# of chart installation. We do not use Helm's CRD installation mechanism as that
|
||||
# does not allow for upgrading CRDs.
|
||||
|
||||
@@ -286,8 +286,6 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: ENABLE_CONNECTOR
|
||||
value: "false"
|
||||
- name: CLIENT_ID_FILE
|
||||
value: /oauth/client_id
|
||||
- name: CLIENT_SECRET_FILE
|
||||
|
||||
@@ -62,7 +62,6 @@ func main() {
|
||||
priorityClassName = defaultEnv("PROXY_PRIORITY_CLASS_NAME", "")
|
||||
tags = defaultEnv("PROXY_TAGS", "tag:k8s")
|
||||
tsFirewallMode = defaultEnv("PROXY_FIREWALL_MODE", "")
|
||||
tsEnableConnector = defaultBool("ENABLE_CONNECTOR", false)
|
||||
)
|
||||
|
||||
var opts []kzap.Opts
|
||||
@@ -93,7 +92,7 @@ func main() {
|
||||
maybeLaunchAPIServerProxy(zlog, restConfig, s, mode)
|
||||
// TODO (irbekrm): gather the reconciler options into an opts struct
|
||||
// rather than passing a million of them in one by one.
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode, tsEnableConnector)
|
||||
runReconcilers(zlog, s, tsNamespace, restConfig, tsClient, image, priorityClassName, tags, tsFirewallMode)
|
||||
}
|
||||
|
||||
// initTSNet initializes the tsnet.Server and logs in to Tailscale. It uses the
|
||||
@@ -201,7 +200,7 @@ waitOnline:
|
||||
|
||||
// runReconcilers starts the controller-runtime manager and registers the
|
||||
// ServiceReconciler. It blocks forever.
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string, enableConnector bool) {
|
||||
func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string, restConfig *rest.Config, tsClient *tailscale.Client, image, priorityClassName, tags, tsFirewallMode string) {
|
||||
var (
|
||||
isDefaultLoadBalancer = defaultBool("OPERATOR_DEFAULT_LOAD_BALANCER", false)
|
||||
)
|
||||
@@ -222,9 +221,7 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
&appsv1.StatefulSet{}: nsFilter,
|
||||
},
|
||||
},
|
||||
}
|
||||
if enableConnector {
|
||||
mgrOpts.Scheme = tsapi.GlobalScheme
|
||||
Scheme: tsapi.GlobalScheme,
|
||||
}
|
||||
mgr, err := manager.New(restConfig, mgrOpts)
|
||||
if err != nil {
|
||||
@@ -278,22 +275,20 @@ func runReconcilers(zlog *zap.SugaredLogger, s *tsnet.Server, tsNamespace string
|
||||
startlog.Fatalf("could not create controller: %v", err)
|
||||
}
|
||||
|
||||
if enableConnector {
|
||||
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("subnetrouter"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Connector{}).
|
||||
Watches(&appsv1.StatefulSet{}, connectorFilter).
|
||||
Watches(&corev1.Secret{}, connectorFilter).
|
||||
Complete(&ConnectorReconciler{
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("connector-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
}
|
||||
connectorFilter := handler.EnqueueRequestsFromMapFunc(managedResourceHandlerForType("connector"))
|
||||
err = builder.ControllerManagedBy(mgr).
|
||||
For(&tsapi.Connector{}).
|
||||
Watches(&appsv1.StatefulSet{}, connectorFilter).
|
||||
Watches(&corev1.Secret{}, connectorFilter).
|
||||
Complete(&ConnectorReconciler{
|
||||
ssr: ssr,
|
||||
recorder: eventRecorder,
|
||||
Client: mgr.GetClient(),
|
||||
logger: zlog.Named("connector-reconciler"),
|
||||
clock: tstime.DefaultClock{},
|
||||
})
|
||||
if err != nil {
|
||||
startlog.Fatal("could not create connector reconciler: %v", err)
|
||||
}
|
||||
startlog.Infof("Startup complete, operator running, version: %s", version.Long())
|
||||
if err := mgr.Start(signals.SetupSignalHandler()); err != nil {
|
||||
|
||||
@@ -214,18 +214,19 @@ const maxStatefulSetNameLength = 63 - 10 - 1
|
||||
// generation will NOT result in a StatefulSet name longer than 52 chars.
|
||||
// This is done because of https://github.com/kubernetes/kubernetes/issues/64023.
|
||||
func statefulSetNameBase(parent string) string {
|
||||
|
||||
base := fmt.Sprintf("ts-%s-", parent)
|
||||
|
||||
// Calculate what length name GenerateName returns for this base.
|
||||
generator := names.SimpleNameGenerator
|
||||
generatedName := generator.GenerateName(base)
|
||||
|
||||
if excess := len(generatedName) - maxStatefulSetNameLength; excess > 0 {
|
||||
base = base[:len(base)-excess-1] // take extra char off to make space for hyphen
|
||||
base = base + "-" // re-instate hyphen
|
||||
for {
|
||||
generatedName := generator.GenerateName(base)
|
||||
excess := len(generatedName) - maxStatefulSetNameLength
|
||||
if excess <= 0 {
|
||||
return base
|
||||
}
|
||||
base = base[:len(base)-1-excess] // cut off the excess chars
|
||||
if !strings.HasSuffix(base, "-") { // dash may have been cut by the generator
|
||||
base = base + "-"
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, logger *zap.SugaredLogger, sts *tailscaleSTSConfig) (*corev1.Service, error) {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,32 +22,20 @@ import (
|
||||
// https://github.com/kubernetes/kubernetes/blob/v1.28.4/staging/src/k8s.io/apiserver/pkg/storage/names/generate.go#L45.
|
||||
// https://github.com/kubernetes/kubernetes/pull/116430
|
||||
func Test_statefulSetNameBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{
|
||||
name: "43 chars",
|
||||
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb",
|
||||
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
|
||||
},
|
||||
{
|
||||
name: "44 chars",
|
||||
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xbo",
|
||||
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9xb-",
|
||||
},
|
||||
{
|
||||
name: "42 chars",
|
||||
in: "oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x",
|
||||
out: "ts-oidhexl9o832hcbhyg4uz6o0s7u9uae54h5k8ofs9x-",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := statefulSetNameBase(tt.in); got != tt.out {
|
||||
t.Errorf("stsNamePrefix(%s) = %q, want %s", tt.in, got, tt.out)
|
||||
}
|
||||
})
|
||||
// Service name lengths can be 1 - 63 chars, be paranoid and test them all.
|
||||
var b strings.Builder
|
||||
for b.Len() < 63 {
|
||||
if _, err := b.WriteString("a"); err != nil {
|
||||
t.Fatalf("error writing to string builder: %v", err)
|
||||
}
|
||||
baseLength := len(b.String())
|
||||
if baseLength > 43 {
|
||||
baseLength = 43 // currently 43 is the max base length
|
||||
}
|
||||
wantsNameR := regexp.MustCompile(`^ts-a{` + fmt.Sprint(baseLength) + `}-$`) // to match a string like ts-aaaa-
|
||||
gotName := statefulSetNameBase(b.String())
|
||||
if !wantsNameR.MatchString(gotName) {
|
||||
t.Fatalf("expected string %s to match regex %s ", gotName, wantsNameR.String()) // fatal rather than error as this test is called 63 times
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1573,6 +1573,17 @@ func (c *sclient) sendMeshUpdates() error {
|
||||
c.s.mu.Lock()
|
||||
defer c.s.mu.Unlock()
|
||||
|
||||
// allow all happened-before mesh update request goroutines to complete, if
|
||||
// we don't finish the task we'll queue another below.
|
||||
drainUpdates:
|
||||
for {
|
||||
select {
|
||||
case <-c.meshUpdate:
|
||||
default:
|
||||
break drainUpdates
|
||||
}
|
||||
}
|
||||
|
||||
writes := 0
|
||||
for _, pcs := range c.peerStateChange {
|
||||
if c.bw.Available() <= frameHeaderLen+keyLen {
|
||||
|
||||
81
util/httphdr/auth.go
Normal file
81
util/httphdr/auth.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httphdr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: Must authorization parameters be valid UTF-8?
|
||||
|
||||
// AuthScheme is an authorization scheme per RFC 7235.
|
||||
// Per section 2.1, the "Authorization" header is formatted as:
|
||||
//
|
||||
// Authorization: <auth-scheme> <auth-parameter>
|
||||
//
|
||||
// A scheme implementation must self-report the <auth-scheme> name and
|
||||
// provide the ability to marshal and unmarshal the <auth-parameter>.
|
||||
//
|
||||
// For concrete implementations, see [Basic] and [Bearer].
|
||||
type AuthScheme interface {
|
||||
// AuthScheme is the authorization scheme name.
|
||||
// It must be valid according to RFC 7230, section 3.2.6.
|
||||
AuthScheme() string
|
||||
|
||||
// MarshalAuth marshals the authorization parameter for the scheme.
|
||||
MarshalAuth() (string, error)
|
||||
|
||||
// UnmarshalAuth unmarshals the authorization parameter for the scheme.
|
||||
UnmarshalAuth(string) error
|
||||
}
|
||||
|
||||
// BasicAuth is the Basic authorization scheme as defined in RFC 2617.
|
||||
type BasicAuth struct {
|
||||
Username string // must not contain ':' per section 2
|
||||
Password string
|
||||
}
|
||||
|
||||
func (BasicAuth) AuthScheme() string { return "Basic" }
|
||||
|
||||
func (a BasicAuth) MarshalAuth() (string, error) {
|
||||
if strings.IndexByte(a.Username, ':') >= 0 {
|
||||
return "", fmt.Errorf("invalid username: contains a colon")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(a.Username + ":" + a.Password)), nil
|
||||
}
|
||||
|
||||
func (a *BasicAuth) UnmarshalAuth(s string) error {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid basic authorization: %w", err)
|
||||
}
|
||||
i := bytes.IndexByte(b, ':')
|
||||
if i < 0 {
|
||||
return fmt.Errorf("invalid basic authorization: missing a colon")
|
||||
}
|
||||
a.Username = string(b[:i])
|
||||
a.Password = string(b[i+len(":"):])
|
||||
return nil
|
||||
}
|
||||
|
||||
// BearerAuth is the Bearer Token authorization scheme as defined in RFC 6750.
|
||||
type BearerAuth struct {
|
||||
Token string // usually a base64-encoded string per section 2.1
|
||||
}
|
||||
|
||||
func (BearerAuth) AuthScheme() string { return "Bearer" }
|
||||
|
||||
func (a BearerAuth) MarshalAuth() (string, error) {
|
||||
// TODO: Verify that token is valid base64?
|
||||
return a.Token, nil
|
||||
}
|
||||
|
||||
func (a *BearerAuth) UnmarshalAuth(s string) error {
|
||||
// TODO: Verify that token is valid base64?
|
||||
a.Token = s
|
||||
return nil
|
||||
}
|
||||
43
util/httpio/context.go
Normal file
43
util/httpio/context.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/util/httphdr"
|
||||
)
|
||||
|
||||
type headerKey struct{}
|
||||
|
||||
// WithHeader specifies the HTTP header to use with a client request.
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// ctx = httpio.WithHeader(ctx, http.Header{"DD-API-KEY": ...})
|
||||
func WithHeader(ctx context.Context, hdr http.Header) context.Context {
|
||||
return context.WithValue(ctx, headerKey{}, hdr)
|
||||
}
|
||||
|
||||
type authKey struct{}
|
||||
|
||||
// WithAuth specifies an "Authorization" header to use with a client request.
|
||||
// This takes precedence over any "Authorization" header that may be present
|
||||
// in the [http.Header] provided to [WithHeader].
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// ctx = httpio.WithAuth(ctx, httphdr.BasicAuth{
|
||||
// Username: "admin",
|
||||
// Password: "password",
|
||||
// })
|
||||
func WithAuth(ctx context.Context, auth httphdr.AuthScheme) context.Context {
|
||||
return context.WithValue(ctx, authKey{}, auth)
|
||||
}
|
||||
|
||||
// TODO: Add extraction functionality to retrieve the original
|
||||
// *http.Request and http.ResponseWriter for use with [Handler].
|
||||
93
util/httpio/endpoint.go
Normal file
93
util/httpio/endpoint.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Endpoint annotates an HTTP method and path with input and output types.
|
||||
//
|
||||
// The intent is to declare this in a shared package between client and server
|
||||
// implementations as a means to structurally describe how they interact.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// package tsapi
|
||||
//
|
||||
// const BaseURL = "https://api.tailscale.com/api/v2/"
|
||||
//
|
||||
// var (
|
||||
// GetDevice = httpio.Endpoint[GetDeviceRequest, GetDeviceResponse]{Method: "GET", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
|
||||
// DeleteDevice = httpio.Endpoint[DeleteDeviceRequest, DeleteDeviceResponse]{Method: "DELETE", Pattern: "/device/{DeviceID}"}.WithHost(BaseURL)
|
||||
// )
|
||||
//
|
||||
// type GetDeviceRequest struct {
|
||||
// ID int `urlpath:"DeviceID"`
|
||||
// Fields []string `urlquery:"fields"`
|
||||
// ...
|
||||
// }
|
||||
// type GetDeviceResponse struct {
|
||||
// ID int `json:"id"`
|
||||
// Addresses []netip.Addr `json:"addresses"`
|
||||
// ...
|
||||
// }
|
||||
// type DeleteDeviceRequest struct { ... }
|
||||
// type DeleteDeviceResponse struct { ... }
|
||||
//
|
||||
// Example usage by client code:
|
||||
//
|
||||
// ctx = httpio.WithAuth(ctx, ...)
|
||||
// device, err := tsapi.GetDevice.Do(ctx, {ID: 1234})
|
||||
//
|
||||
// Example usage by server code:
|
||||
//
|
||||
// mux := http.NewServeMux()
|
||||
// mux.Handle(tsapi.GetDevice.String(), checkAuth(httpio.Handler(getDevice)))
|
||||
// mux.Handle(tsapi.DeleteDevice.String(), checkAuth(httpio.Handler(deleteDevice)))
|
||||
//
|
||||
// func checkAuth(http.Handler) http.Handler { ... }
|
||||
// func getDevice(ctx context.Context, in GetDeviceRequest) (out GetDeviceResponse, err error) { ... }
|
||||
// func deleteDevice(ctx context.Context, in DeleteDeviceRequest) (out DeleteDeviceResponse, err error) { ... }
|
||||
type Endpoint[In Request, Out Response] struct {
|
||||
// Method is a valid HTTP method (e.g., "GET").
|
||||
Method string
|
||||
// Pattern must be a pattern that complies with [mux.ServeMux.Handle] and
|
||||
// not be preceded by a method or host (e.g., "/api/v2/device/{DeviceID}").
|
||||
// It must start with a leading "/".
|
||||
Pattern string
|
||||
}
|
||||
|
||||
// String returns a combination of the method and pattern,
|
||||
// which is a valid pattern for [mux.ServeMux.Handle].
|
||||
func (e Endpoint[In, Out]) String() string { return e.Method + " " + e.Pattern }
|
||||
|
||||
// Do performs an HTTP call to the target endpoint at the specified host.
|
||||
// The hostPrefix must be a URL prefix containing the scheme and host,
|
||||
// but not contain any URL query parameters (e.g., "https://api.tailscale.com/api/v2/").
|
||||
func (e Endpoint[In, Out]) Do(ctx context.Context, hostPrefix string, in In, opts ...Option) (out Out, err error) {
|
||||
return Do[In, Out](ctx, e.Method, strings.TrimRight(hostPrefix, "/")+e.Pattern, in, opts...)
|
||||
}
|
||||
|
||||
// TODO: Should hostPrefix be a *url.URL?
|
||||
|
||||
// WithHost constructs a [HostedEndpoint],
|
||||
// which is an HTTP endpoint hosted at a particular URL prefix.
|
||||
func (e Endpoint[In, Out]) WithHost(hostPrefix string) HostedEndpoint[In, Out] {
|
||||
return HostedEndpoint[In, Out]{Prefix: hostPrefix, Endpoint: e}
|
||||
}
|
||||
|
||||
// HostedEndpoint is an HTTP endpoint hosted under a particular URL prefix.
|
||||
type HostedEndpoint[In Request, Out Response] struct {
|
||||
// Prefix is a URL prefix containing the scheme, host, and
|
||||
// an optional path prefix (e.g., "https://api.tailscale.com/api/v2/").
|
||||
Prefix string
|
||||
Endpoint[In, Out]
|
||||
}
|
||||
|
||||
// Do performs an HTTP call to the target hosted endpoint.
|
||||
func (e HostedEndpoint[In, Out]) Do(ctx context.Context, in In, opts ...Option) (out Out, err error) {
|
||||
return Do[In, Out](ctx, e.Method, strings.TrimSuffix(e.Prefix, "/")+e.Pattern, in, opts...)
|
||||
}
|
||||
121
util/httpio/httpio.go
Normal file
121
util/httpio/httpio.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package httpio assists in handling HTTP operations on structured
|
||||
// input and output types. It automatically handles encoding of data
|
||||
// in the URL path, URL query parameters, and the HTTP body.
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
// Request is a structured Go type that contains fields representing arguments
|
||||
// in the URL path, URL query parameters, and optionally the HTTP request body.
|
||||
//
|
||||
// Typically, this is a Go struct:
|
||||
//
|
||||
// - with fields tagged as `urlpath` to represent arguments in the URL path
|
||||
// (e.g., "/tailnet/{tailnetId}/devices/{deviceId}").
|
||||
// See [tailscale.com/util/httpio/urlpath] for details.
|
||||
//
|
||||
// - with fields tagged as `urlquery` to represent URL query parameters
|
||||
// (e.g., "?after=18635&limit=5").
|
||||
// See [tailscale.com/util/httpio/urlquery] for details.
|
||||
//
|
||||
// - with possibly other fields used to serialize as the HTTP body.
|
||||
// By default, [encoding/json] is used to marshal the entire struct value.
|
||||
// To prevent fields specific to `urlpath` or `urlquery` from being marshaled
|
||||
// as part of the body, explicitly ignore those fields with `json:"-"`.
|
||||
// An HTTP body is only populated if there are any exported fields
|
||||
// without the `urlpath` or `urlquery` struct tags.
|
||||
//
|
||||
// Since GET and DELETE methods usually have no associated body,
|
||||
// requests for such methods often only have `urlpath` and `urlquery` fields.
|
||||
//
|
||||
// Example GET request type:
|
||||
//
|
||||
// type GetDevicesRequest struct {
|
||||
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId"`
|
||||
//
|
||||
// Limit uint `urlquery:"limit"`
|
||||
// After tailcfg.DeviceID `urlquery:"after"`
|
||||
// }
|
||||
//
|
||||
// Example PUT request type:
|
||||
//
|
||||
// type PutDeviceRequest struct {
|
||||
// TailnetID tailcfg.TailnetID `urlpath:"tailnetId" json:"-"`
|
||||
// DeviceID tailcfg.DeviceID `urlpath:"deviceId" json:"-"`
|
||||
//
|
||||
// Hostname string `json:"hostname,omitempty"``
|
||||
// IPv4 netip.IPAddr `json:"ipv4,omitzero"``
|
||||
// }
|
||||
//
|
||||
// By convention, request struct types are named "{Method}{Resource}Request",
|
||||
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
|
||||
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
|
||||
type Request = any
|
||||
|
||||
// Response is a structured Go type to represent the HTTP response body.
|
||||
//
|
||||
// By default, [encoding/json] is used to unmarshal the response value.
|
||||
// Unlike [Request], there is no support for `urlpath` and `urlquery` struct tags.
|
||||
//
|
||||
// Example response type:
|
||||
//
|
||||
// type GetDevicesResponses struct {
|
||||
// Devices []Device `json:"devices"`
|
||||
// Error ErrorResponse `json:"error"`
|
||||
// }
|
||||
//
|
||||
// By convention, response struct types are named "{Method}{Resource}Response",
|
||||
// where {Method} is the HTTP method (e.g., "Post, "Get", "Put", "Delete", etc.)
|
||||
// and {Resource} is some resource acted upon (e.g., "Device", "Routes", etc.).
|
||||
type Response = any
|
||||
|
||||
// Handler wraps a caller-provided handle function that operates on
|
||||
// concrete input and output types and returns a [http.Handler] function.
|
||||
func Handler[In Request, Out Response](handle func(ctx context.Context, in In) (out Out, err error), opts ...Option) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: How do we respond to the user if err is non-nil?
|
||||
// Do we default to status 500?
|
||||
panic("not implemented")
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Should url be a *url.URL? In the usage below, the caller should not pass query parameters.
|
||||
|
||||
// Post performs a POST call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Post[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.POST, url, in, opts...)
|
||||
}
|
||||
|
||||
// Get performs a GET call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Get[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.GET, url, in, opts...)
|
||||
}
|
||||
|
||||
// Put performs a PUT call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Put[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.PUT, url, in, opts...)
|
||||
}
|
||||
|
||||
// Delete performs a DELETE call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Delete[In Request, Out Response](ctx context.Context, url string, in In, opts ...Option) (Out, error) {
|
||||
return Do[In, Out](ctx, httpm.DELETE, url, in, opts...)
|
||||
}
|
||||
|
||||
// Do performs an HTTP method call to the provided url with the given input
|
||||
// and returns the response output.
|
||||
func Do[In Request, Out Response](ctx context.Context, method, url string, in In, opts ...Option) (out Out, err error) {
|
||||
// TOOD: If the server returned a non-2xx code, we should report a Go error.
|
||||
panic("not implemented")
|
||||
}
|
||||
44
util/httpio/options.go
Normal file
44
util/httpio/options.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package httpio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Option is an option to alter the behavior of [httpio] functionality.
|
||||
type Option interface{ option() }
|
||||
|
||||
// WithClient specifies the [http.Client] to use in client-initiated requests.
|
||||
// It only affects [Do], [Get], [Post], [Put], and [Delete].
|
||||
// It has no effect on [Handler].
|
||||
func WithClient(c *http.Client) Option {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// WithMarshaler specifies an marshaler to use for a particular "Content-Type".
|
||||
//
|
||||
// For client-side requests (e.g., [Do], [Get], [Post], [Put], and [Delete]),
|
||||
// the first specified encoder is used to specify the "Content-Type" and
|
||||
// to marshal the HTTP request body.
|
||||
//
|
||||
// For server-side responses (e.g., [Handler]), the first match between
|
||||
// the client-provided "Accept" header is used to select the encoder to use.
|
||||
// If no match is found, the first specified encoder is used regardless.
|
||||
//
|
||||
// If no encoder is specified, by default the "application/json" content type
|
||||
// is used with the [encoding/json] as the marshal implementation.
|
||||
func WithMarshaler(contentType string, marshal func(io.Writer, any) error) Option {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
// WithUnmarshaler specifies an unmarshaler to use for a particular "Content-Type".
|
||||
//
|
||||
// For both client-side responses and server-side requests,
|
||||
// the provided "Content-Type" header is used to select which decoder to use.
|
||||
// If no match is found, the first specified encoder is used regardless.
|
||||
func WithUnmarshaler(contentType string, unmarshal func(io.Reader, any) error) Option {
|
||||
panic("not implemented")
|
||||
}
|
||||
10
util/httpio/urlpath/urlpath.go
Normal file
10
util/httpio/urlpath/urlpath.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Package urpath TODO
|
||||
package urlpath
|
||||
|
||||
// option is an option to alter behavior of Marshal and Unmarshal.
|
||||
// Currently, there are no defined options.
|
||||
type option interface{ option() }
|
||||
|
||||
func Marshal(pattern string, val any, opts ...option) (path string, err error)
|
||||
|
||||
func Unmarshal(pattern, path string, val any, opts ...option) (err error)
|
||||
10
util/httpio/urlquery/urlquery.go
Normal file
10
util/httpio/urlquery/urlquery.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Package urlquery TODO
|
||||
package urlquery
|
||||
|
||||
// option is an option to alter behavior of Marshal and Unmarshal.
|
||||
// Currently, there are no defined options.
|
||||
type option interface{ option() }
|
||||
|
||||
func Marshal(val any, opts ...option) (query string, err error)
|
||||
|
||||
func Unmarshal(query string, val any, opts ...option) (err error)
|
||||
Reference in New Issue
Block a user