mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-05 20:26:47 +02:00
Exposes a local port on the tailnet under a chosen hostname. Raw TCP by default; --http or --https reverse-proxy with Tailscale-User-* identity headers from WhoIs, matching tailscaled's serve header conventions. Useful as a one-shot to put a dev server on the tailnet. Fixes #19467 Change-Id: I79f63cfbbedf7e40cf0f1f51cbae8df86ae90cdf Signed-off-by: Fernando Serboncini <fserb@tailscale.com>
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// The tsnet-proxy command exposes a local port on the tailnet under a
|
|
// chosen hostname. By default it proxies raw TCP; pass --http to reverse
|
|
// proxy as HTTP, or --https to reverse proxy as HTTPS with an auto-issued
|
|
// Tailscale cert. Both HTTP modes inject Tailscale-User-* identity headers
|
|
// from WhoIs.
|
|
//
|
|
// Arguments are <name> <local> [tailnet]: local is the port on localhost
|
|
// to proxy to and tailnet is the port to expose on the tailnet. If tailnet
|
|
// is omitted, it defaults to 443 for --https, 80 for --http, and the local
|
|
// port otherwise.
|
|
//
|
|
// go run ./cmd/tsnet-proxy myapp 8080 # raw TCP, tailnet :8080
|
|
// go run ./cmd/tsnet-proxy myapp 22 2222 # raw TCP, tailnet :2222
|
|
// go run ./cmd/tsnet-proxy --http myapp 8080 # tailnet :80
|
|
// go run ./cmd/tsnet-proxy --https myapp 8080 # tailnet :443
|
|
//
|
|
// Or run directly from the module, no checkout required:
|
|
//
|
|
// go run tailscale.com/cmd/tsnet-proxy@latest myapp 8080
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"unicode/utf8"
|
|
|
|
"tailscale.com/client/local"
|
|
"tailscale.com/tsnet"
|
|
)
|
|
|
|
func main() {
|
|
asHTTP := flag.Bool("http", false, "reverse proxy as HTTP and inject Tailscale-User-* headers")
|
|
asHTTPS := flag.Bool("https", false, "reverse proxy as HTTPS with an auto-issued Tailscale cert; implies --http")
|
|
dir := flag.String("dir", "", "directory to persist tsnet state (default: per-user config dir)")
|
|
verbose := flag.Bool("v", false, "verbose tsnet backend logs")
|
|
flag.Usage = func() {
|
|
fmt.Fprintf(flag.CommandLine.Output(), "usage: %s [flags] <name> <local> [tailnet]\n", flag.CommandLine.Name())
|
|
flag.PrintDefaults()
|
|
}
|
|
flag.Parse()
|
|
|
|
if n := flag.NArg(); n != 2 && n != 3 {
|
|
flag.Usage()
|
|
os.Exit(2)
|
|
}
|
|
name := flag.Arg(0)
|
|
localPort, err := parsePort(flag.Arg(1))
|
|
if err != nil {
|
|
log.Fatalf("invalid local port %q: %v", flag.Arg(1), err)
|
|
}
|
|
tailnetPort := defaultTailnetPort(localPort, *asHTTP, *asHTTPS)
|
|
if flag.NArg() == 3 {
|
|
tailnetPort, err = parsePort(flag.Arg(2))
|
|
if err != nil {
|
|
log.Fatalf("invalid tailnet port %q: %v", flag.Arg(2), err)
|
|
}
|
|
}
|
|
|
|
target := "localhost:" + strconv.Itoa(localPort)
|
|
addr := ":" + strconv.Itoa(tailnetPort)
|
|
|
|
s := &tsnet.Server{Hostname: name, Dir: *dir}
|
|
if *verbose {
|
|
s.Logf = log.Printf
|
|
}
|
|
defer s.Close()
|
|
|
|
var ln net.Listener
|
|
if *asHTTPS {
|
|
ln, err = s.ListenTLS("tcp", addr)
|
|
} else {
|
|
ln, err = s.Listen("tcp", addr)
|
|
}
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
log.Printf("proxying %s -> %s on tailnet", target, name+addr)
|
|
|
|
if *asHTTP || *asHTTPS {
|
|
lc, err := s.LocalClient()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
targetURL := &url.URL{Scheme: "http", Host: target}
|
|
rp := &httputil.ReverseProxy{
|
|
Rewrite: func(r *httputil.ProxyRequest) {
|
|
r.SetURL(targetURL)
|
|
r.SetXForwarded()
|
|
addTailscaleIdentityHeaders(lc, r)
|
|
},
|
|
}
|
|
log.Fatal(http.Serve(ln, rp))
|
|
}
|
|
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
go proxyTCP(c, target)
|
|
}
|
|
}
|
|
|
|
func parsePort(s string) (int, error) {
|
|
p, err := strconv.Atoi(s)
|
|
if err != nil || p <= 0 || p > 65535 {
|
|
return 0, fmt.Errorf("bad port")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// defaultTailnetPort returns the tailnet port when the user didn't
|
|
// specify one: 443 for HTTPS, 80 for HTTP, else the local port.
|
|
func defaultTailnetPort(local int, asHTTP, asHTTPS bool) int {
|
|
switch {
|
|
case asHTTPS:
|
|
return 443
|
|
case asHTTP:
|
|
return 80
|
|
}
|
|
return local
|
|
}
|
|
|
|
func proxyTCP(c net.Conn, target string) {
|
|
defer c.Close()
|
|
d, err := net.Dial("tcp", target)
|
|
if err != nil {
|
|
log.Printf("dial %s: %v", target, err)
|
|
return
|
|
}
|
|
defer d.Close()
|
|
go io.Copy(d, c)
|
|
io.Copy(c, d)
|
|
}
|
|
|
|
func addTailscaleIdentityHeaders(lc *local.Client, r *httputil.ProxyRequest) {
|
|
r.Out.Header.Del("Tailscale-User-Login")
|
|
r.Out.Header.Del("Tailscale-User-Name")
|
|
r.Out.Header.Del("Tailscale-User-Profile-Pic")
|
|
r.Out.Header.Del("Tailscale-Funnel-Request")
|
|
r.Out.Header.Del("Tailscale-Headers-Info")
|
|
|
|
who, err := lc.WhoIs(r.In.Context(), r.In.RemoteAddr)
|
|
if err != nil || who == nil || who.Node.IsTagged() {
|
|
return
|
|
}
|
|
r.Out.Header.Set("Tailscale-User-Login", encHeader(who.UserProfile.LoginName))
|
|
r.Out.Header.Set("Tailscale-User-Name", encHeader(who.UserProfile.DisplayName))
|
|
r.Out.Header.Set("Tailscale-User-Profile-Pic", who.UserProfile.ProfilePicURL)
|
|
}
|
|
|
|
// encHeader mirrors the encoding tailscaled's serve path applies to
|
|
// user-provided strings destined for HTTP headers.
|
|
func encHeader(v string) string {
|
|
if !utf8.ValidString(v) {
|
|
return ""
|
|
}
|
|
return mime.QEncoding.Encode("utf-8", v)
|
|
}
|