diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go
index 5457d2fad..450ee197c 100644
--- a/cmd/tailscale/cli/debug.go
+++ b/cmd/tailscale/cli/debug.go
@@ -564,7 +564,8 @@ func runDebugComponentLogs(ctx context.Context, args []string) error {
func runDevStoreSet(ctx context.Context, args []string) error {
// TODO(bradfitz): remove this temporary (2022-11-09) hack once
// profile stuff and serving CLI commands are more fleshed out.
- if len(args) >= 1 && strings.HasPrefix(args[0], "_serve/") {
+ isServe := len(args) >= 1 && strings.HasPrefix(args[0], "_serve/")
+ if isServe {
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return err
@@ -584,6 +585,11 @@ func runDevStoreSet(ctx context.Context, args []string) error {
if err != nil {
return err
}
+ if isServe {
+ if err := json.Unmarshal(valb, new(ipn.ServeConfig)); err != nil {
+ return fmt.Errorf("invalid JSON: %w", err)
+ }
+ }
val = string(valb)
}
return localClient.SetDevStoreKeyValue(ctx, key, val)
diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go
index 3a2374a40..48df748b4 100644
--- a/ipn/ipn_clone.go
+++ b/ipn/ipn_clone.go
@@ -118,6 +118,7 @@ func (src *HTTPHandler) Clone() *HTTPHandler {
var _HTTPHandlerCloneNeedsRegeneration = HTTPHandler(struct {
Path string
Proxy string
+ Text string
}{})
// Clone makes a deep copy of WebServerConfig.
diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go
index 56444edbc..634158341 100644
--- a/ipn/ipn_view.go
+++ b/ipn/ipn_view.go
@@ -285,11 +285,13 @@ func (v *HTTPHandlerView) UnmarshalJSON(b []byte) error {
func (v HTTPHandlerView) Path() string { return v.ж.Path }
func (v HTTPHandlerView) Proxy() string { return v.ж.Proxy }
+func (v HTTPHandlerView) Text() string { return v.ж.Text }
// A compilation failure here means this code must be regenerated, with the command at the top of this file.
var _HTTPHandlerViewNeedsRegeneration = HTTPHandler(struct {
Path string
Proxy string
+ Text string
}{})
// View returns a readonly view of WebServerConfig.
diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go
index 3e09a650f..66e15e12b 100644
--- a/ipn/ipnlocal/local.go
+++ b/ipn/ipnlocal/local.go
@@ -6,7 +6,6 @@
import (
"context"
- "crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
@@ -4086,41 +4085,3 @@ func (b *LocalBackend) SetDevStateStore(key, value string) error {
func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
return b.shouldInterceptTCPPortAtomic.Load()(port)
}
-
-var runDevWebServer = envknob.RegisterBool("TS_DEV_WEBSERVER")
-
-func (b *LocalBackend) HandleInterceptedTCPConn(c net.Conn) {
- if !runDevWebServer() {
- b.logf("localbackend: closing TCP conn from %v to %v", c.RemoteAddr(), c.LocalAddr())
- c.Close()
- return
- }
-
- // TODO(bradfitz): look up how; sniff SNI if ambiguous
- hs := &http.Server{
- TLSConfig: &tls.Config{
- GetCertificate: b.getTLSServeCert,
- },
- Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- io.WriteString(w, "
hello world
this is tailscaled")
- }),
- }
- hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
-}
-
-func (b *LocalBackend) getTLSServeCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
- if hi == nil || hi.ServerName == "" {
- return nil, errors.New("no SNI ServerName")
- }
- ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
- defer cancel()
- pair, err := b.GetCertPEM(ctx, hi.ServerName)
- if err != nil {
- return nil, err
- }
- cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
- if err != nil {
- return nil, err
- }
- return &cert, nil
-}
diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go
new file mode 100644
index 000000000..ff1cb1741
--- /dev/null
+++ b/ipn/ipnlocal/serve.go
@@ -0,0 +1,107 @@
+// 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 ipnlocal
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ pathpkg "path"
+ "time"
+
+ "tailscale.com/envknob"
+ "tailscale.com/ipn"
+ "tailscale.com/net/netutil"
+)
+
+var runDevWebServer = envknob.RegisterBool("TS_DEV_WEBSERVER")
+
+func (b *LocalBackend) HandleInterceptedTCPConn(c net.Conn) {
+ if !runDevWebServer() {
+ b.logf("localbackend: closing TCP conn from %v to %v", c.RemoteAddr(), c.LocalAddr())
+ c.Close()
+ return
+ }
+
+ // TODO(bradfitz): look up how; sniff SNI if ambiguous
+ hs := &http.Server{
+ TLSConfig: &tls.Config{
+ GetCertificate: b.getTLSServeCert,
+ },
+ Handler: http.HandlerFunc(b.serveWebHandler),
+ }
+ hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "")
+}
+
+func (b *LocalBackend) getServeHandler(r *http.Request) (_ *ipn.HTTPHandler, ok bool) {
+ if r.TLS == nil {
+ return nil, false
+ }
+
+ sni := r.TLS.ServerName
+ port := "443" // TODO(bradfitz): fix
+ key := ipn.HostPort(net.JoinHostPort(sni, port))
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ wsc, ok := b.serveConfig.Web[key]
+ if !ok {
+ return nil, false
+ }
+ path := r.URL.Path
+ for {
+ if h, ok := wsc.Handlers[path]; ok {
+ return h, true
+ }
+ if path == "/" {
+ return nil, false
+ }
+ path = pathpkg.Dir(path)
+ }
+}
+
+func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) {
+ h, ok := b.getServeHandler(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if s := h.Text; s != "" {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ io.WriteString(w, s)
+ return
+ }
+ if v := h.Path; v != "" {
+ io.WriteString(w, "TODO(bradfitz): serve file")
+ return
+ }
+ if v := h.Proxy; v != "" {
+ io.WriteString(w, "TODO(bradfitz): proxy")
+ return
+ }
+
+ http.Error(w, "empty handler", 500)
+}
+
+func (b *LocalBackend) getTLSServeCert(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ if hi == nil || hi.ServerName == "" {
+ return nil, errors.New("no SNI ServerName")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ pair, err := b.GetCertPEM(ctx, hi.ServerName)
+ if err != nil {
+ return nil, err
+ }
+ cert, err := tls.X509KeyPair(pair.CertPEM, pair.KeyPEM)
+ if err != nil {
+ return nil, err
+ }
+ return &cert, nil
+}
diff --git a/ipn/store.go b/ipn/store.go
index dff2b2ee4..85cf0a8e5 100644
--- a/ipn/store.go
+++ b/ipn/store.go
@@ -121,6 +121,8 @@ type HTTPHandler struct {
Path string `json:",omitempty"` // absolute path to directory or file to serve
Proxy string `json:",omitempty"` // http://localhost:3000/, localhost:3030, 3030
+ Text string `json:",omitempty"` // plaintext to serve (primarily for testing)
+
// TODO(bradfitz): bool to not enumerate directories? TTL on mapping for
// temporary ones? Error codes? Redirects?
}