Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Flags (defaults come from ~/.tailscale-proxy/config.json if present):
tailnet host resolve the public funnel name (persists)
--log-requests Log each proxied request (default on)
--quiet Disable per-request logging
--docker Also query Docker API for containers (default off)
-h, --help Show this help

Press Ctrl-C to stop — the Serve/Funnel entry is reset automatically on exit.
Expand All @@ -93,6 +94,7 @@ type startOpts struct {
forwardHost bool
quiet bool
acceptDNS string
docker bool
}

// modeOf returns the exposure mode for the private flag.
Expand Down Expand Up @@ -125,6 +127,7 @@ func cmdStart(argv []string) int {
fs.BoolVar(&o.forwardHost, "forward-host", cfg.ForwardHost, "forward the public host to apps (X-Forwarded-Host/Proto); default presents a local request")
fs.StringVar(&o.acceptDNS, "accept-dns", cfg.AcceptDNS, "optionally set Tailscale MagicDNS (true|false) on start; default unset = leave it alone")
fs.BoolVar(&o.quiet, "quiet", false, "disable per-request logging")
fs.BoolVar(&o.docker, "docker", cfg.Docker, "also query Docker API for containers (default off)")
fs.BoolVar(&o.bg, "bg", false, "run detached in background")
var fg bool
fs.BoolVar(&fg, "fg", false, "run in foreground (default)")
Expand Down Expand Up @@ -186,7 +189,7 @@ func cmdStart(argv []string) int {
fmt.Printf("set tailscale accept-dns=%s (persists after exit; revert with: tailscale set --accept-dns=%s)\n", o.acceptDNS, revert)
}

dcfg := discoverConfig{rng: rng, all: o.all, runtimes: parseRuntimes(o.runtimesRaw)}
dcfg := discoverConfig{rng: rng, all: o.all, runtimes: parseRuntimes(o.runtimesRaw), docker: o.docker}
disc := newDiscoverer(runner)

if !printChecks(runDoctor(runner, disc, dcfg, mode)) && !o.proxyOnly {
Expand Down
4 changes: 3 additions & 1 deletion core/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func cmdConfigure(argv []string) int {
fs.IntVar(&cfg.DeregisterCycles, "deregister-cycles", cfg.DeregisterCycles, "missing scans before removal")
fs.BoolVar(&cfg.LogRequests, "log-requests", cfg.LogRequests, "log each proxied request")
fs.BoolVar(&cfg.ForwardHost, "forward-host", cfg.ForwardHost, "forward the public host to apps")
fs.BoolVar(&cfg.Docker, "docker", cfg.Docker, "also query Docker API for containers")
fs.StringVar(&cfg.AcceptDNS, "accept-dns", cfg.AcceptDNS, "set Tailscale MagicDNS (true|false) on start; empty = leave it alone")
if err := fs.Parse(argv); err != nil {
if err == flag.ErrHelp {
Expand Down Expand Up @@ -94,10 +95,11 @@ func queryConfig(argv []string) (Mode, discoverConfig, int) {
runtimesRaw := fs.String("runtimes", cfg.Runtimes, "comma-separated runtimes")
private := fs.Bool("private", cfg.Private, "private (Serve) mode")
httpsPort := fs.Int("https-port", cfg.HTTPSPort, "public/tailnet HTTPS port")
docker := fs.Bool("docker", cfg.Docker, "also query Docker API for containers")
_ = fs.Parse(argv)
rng, err := parsePortRange(*portsRaw)
if err != nil {
rng = PortRange{Lo: 3000, Hi: 5000}
}
return modeOf(*private), discoverConfig{rng: rng, all: *all, runtimes: parseRuntimes(*runtimesRaw)}, *httpsPort
return modeOf(*private), discoverConfig{rng: rng, all: *all, runtimes: parseRuntimes(*runtimesRaw), docker: *docker}, *httpsPort
}
10 changes: 10 additions & 0 deletions core/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package core

import "testing"

func TestQueryConfigParsesDockerFlag(t *testing.T) {
_, dcfg, _ := queryConfig([]string{"--docker"})
if !dcfg.docker {
t.Fatal("queryConfig should enable Docker discovery from --docker")
}
}
1 change: 1 addition & 0 deletions core/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Config struct {
DeregisterCycles int `json:"deregisterCycles"` // missing scans before removal
ForwardHost bool `json:"forwardHost"` // forward the external host to the app
AcceptDNS string `json:"acceptDns"` // "" = leave Tailscale DNS alone; "true"/"false" = set on start
Docker bool `json:"docker"` // also query Docker API for containers
}

// defaultConfig returns the built-in defaults.
Expand Down
11 changes: 11 additions & 0 deletions core/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ func TestDefaultConfig(t *testing.T) {
if cfg.Private {
t.Error("Private = true, want false")
}
if cfg.Docker {
t.Error("Docker = true, want false")
}
}

func TestLoadConfigFrom_missingReturnsDefaults(t *testing.T) {
Expand Down Expand Up @@ -66,6 +69,7 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
HTTPSPort: 8443,
LogRequests: false,
DeregisterCycles: 10,
Docker: true,
}

if err := saveConfigTo(path, original); err != nil {
Expand All @@ -84,6 +88,13 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
}
}

func TestOptionsFromConfigIncludesDocker(t *testing.T) {
opts := OptionsFromConfig(Config{Docker: true})
if !opts.Docker {
t.Fatal("OptionsFromConfig should copy Docker")
}
}

func TestLoadConfigFrom_partialOverlaysDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
Expand Down
7 changes: 4 additions & 3 deletions core/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Options struct {
DeregisterCycles int
ForwardHost bool
LogRequests bool
Docker bool
ProxyOnly bool // run the proxy only; skip the Serve/Funnel entry
}

Expand All @@ -33,7 +34,7 @@ func OptionsFromConfig(c Config) Options {
Ports: c.Ports, All: c.All, Runtimes: c.Runtimes, Private: c.Private,
Bind: c.Bind, Port: c.Port, Interval: c.Interval, HTTPSPort: c.HTTPSPort,
DeregisterCycles: c.DeregisterCycles, ForwardHost: c.ForwardHost,
LogRequests: c.LogRequests,
LogRequests: c.LogRequests, Docker: c.Docker,
}
}

Expand Down Expand Up @@ -122,7 +123,7 @@ func (c *Controller) Start(o Options) error {

mode := modeOf(o.Private)
disc := newDiscoverer(runner)
dcfg := discoverConfig{rng: rng, all: o.All, runtimes: parseRuntimes(o.Runtimes)}
dcfg := discoverConfig{rng: rng, all: o.All, runtimes: parseRuntimes(o.Runtimes), docker: o.Docker}
store := NewRouteStore(func() ([]Service, []Duplicate, error) { return disc.Discover(dcfg) }, o.DeregisterCycles)
_, _, _, _ = store.refresh()

Expand Down Expand Up @@ -342,6 +343,6 @@ func Doctor(o Options) []Check {
rng = PortRange{Lo: 3000, Hi: 5000}
}
r := execRunner{}
dcfg := discoverConfig{rng: rng, all: o.All, runtimes: parseRuntimes(o.Runtimes)}
dcfg := discoverConfig{rng: rng, all: o.All, runtimes: parseRuntimes(o.Runtimes), docker: o.Docker}
return runDoctor(r, newDiscoverer(r), dcfg, modeOf(o.Private))
}
17 changes: 15 additions & 2 deletions core/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (
// Service is one discovered listening dev server.
type Service struct {
Slug string // URL path segment
Port int // listening port (127.0.0.1)
Port int // upstream port
Host string // upstream host/IP; empty means 127.0.0.1
Runtime string // node|bun|deno or "" (unknown)
Dir string // working directory (may be "")
PID int
Expand All @@ -29,13 +30,15 @@ type discoverConfig struct {
rng PortRange
all bool
runtimes map[string]bool // nil = all known web runtimes
docker bool // also query Docker API for containers
}

// listener is a raw OS-level listening socket (pre-classification). Comm is the
// command name from lsof/netstat (the interpreter, e.g. "node"); PsComm is the
// process's own name from `ps` (e.g. "http-server", or a "go-build" temp path).
type listener struct {
Port int
Host string
PID int
Comm string
PsComm string
Expand Down Expand Up @@ -135,6 +138,9 @@ func projectRootDir(dir string) string {
if dir == "" || dir == "/" {
return ""
}
if !filepath.IsAbs(dir) {
return dir
}
d := dir
for {
for _, m := range projectMarkers {
Expand Down Expand Up @@ -178,7 +184,11 @@ type Duplicate struct {

// serviceOf builds a Service from a raw listener under a given slug.
func serviceOf(l listener, slug string) Service {
return Service{Slug: slug, Port: l.Port, Runtime: runtimeOf(l), Dir: l.Cwd, PID: l.PID}
host := l.Host
if host == "" {
host = "127.0.0.1"
}
return Service{Slug: slug, Port: l.Port, Host: host, Runtime: runtimeOf(l), Dir: l.Cwd, PID: l.PID}
}

// projectBaseSlug derives the clean project slug from a working directory, or
Expand Down Expand Up @@ -326,6 +336,9 @@ func (d *Discoverer) Discover(cfg discoverConfig) ([]Service, []Duplicate, error
if err != nil {
return nil, nil, err
}
if cfg.docker {
ls = d.mergeDockerListeners(ls, cfg.rng)
}
svcs, dups := buildServices(ls, cfg.all, cfg.runtimes)
return svcs, dups, nil
}
142 changes: 142 additions & 0 deletions core/discover_docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//go:build !windows

package core

import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)

const dockerSocketPath = "/var/run/docker.sock"

type dockerPortBinding struct {
PublicPort int `json:"PublicPort"`
PrivatePort int `json:"PrivatePort"`
IP string `json:"IP"`
}

type dockerNetworkInfo struct {
IPAddress string `json:"IPAddress"`
}

type dockerContainerInfo struct {
Names []string `json:"Names"`
Ports []dockerPortBinding `json:"Ports"`
NetworkSettings struct {
Networks map[string]dockerNetworkInfo `json:"Networks"`
} `json:"NetworkSettings"`
}

// dockerListeners queries the Docker API for running containers and returns
// listeners for each port found. Returns nil (not an error) if Docker is
// unavailable — the caller (discover_unix.go) treats this as "no docker
// listeners" rather than a failure.
func (d *Discoverer) dockerListeners(rng PortRange) []listener {
if _, err := os.Stat(dockerSocketPath); err != nil {
return nil
}

client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", dockerSocketPath)
},
},
}

resp, err := client.Get("http://localhost/containers/json")
if err != nil {
return nil
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}

return parseDockerListeners(body, rng)
}

func parseDockerListeners(body []byte, rng PortRange) []listener {
var containers []dockerContainerInfo
if err := json.Unmarshal(body, &containers); err != nil {
return nil
}

var result []listener
for ci, c := range containers {
if len(c.Names) == 0 || len(c.Ports) == 0 {
continue
}
name := strings.TrimPrefix(c.Names[0], "/")
containerIP := firstContainerIP(c.NetworkSettings.Networks)

for pi, p := range c.Ports {
port := p.PublicPort
host := "127.0.0.1"
if port == 0 {
if containerIP == "" {
continue
}
port = p.PrivatePort
host = containerIP
}
if !rng.contains(port) {
continue
}
result = append(result, listener{
Port: port,
Host: host,
PID: syntheticDockerPID(ci, pi),
Comm: "docker",
Cwd: name,
})
}
}

return result
}

func firstContainerIP(networks map[string]dockerNetworkInfo) string {
for _, n := range networks {
if n.IPAddress != "" {
return n.IPAddress
}
}
return ""
}

func syntheticDockerPID(containerIndex, portIndex int) int {
return -1 - containerIndex*1000 - portIndex
}

// dockerAvailable checks if the Docker socket is accessible and the API responds.
func dockerAvailable() bool {
if _, err := os.Stat(dockerSocketPath); err != nil {
return false
}

client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", dockerSocketPath)
},
},
}

resp, err := client.Get("http://localhost/version")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
19 changes: 19 additions & 0 deletions core/discover_docker_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build windows

package core

// dockerListeners is a no-op on Windows — Docker API over Unix socket
// is not available. Returns nil so the caller treats it as "no docker listeners".
func (d *Discoverer) dockerListeners(rng PortRange) []listener {
return nil
}

// mergeDockerListeners is a no-op on Windows.
func (d *Discoverer) mergeDockerListeners(lsofListeners []listener, rng PortRange) []listener {
return lsofListeners
}

// dockerAvailable always returns false on Windows since we rely on Unix sockets.
func dockerAvailable() bool {
return false
}
24 changes: 24 additions & 0 deletions core/discover_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ func (d *Discoverer) listeners(rng PortRange) ([]listener, error) {
return ls, nil
}

// mergeDockerListeners appends Docker-discovered listeners to lsof results,
// deduplicating by port (lsof takes priority).
func (d *Discoverer) mergeDockerListeners(lsofListeners []listener, rng PortRange) []listener {
dockerLs := d.dockerListeners(rng)
if len(dockerLs) == 0 {
return lsofListeners
}

// Build a set of ports already covered by lsof.
covered := make(map[int]bool, len(lsofListeners))
for _, l := range lsofListeners {
covered[l.Port] = true
}

for _, dl := range dockerLs {
if !covered[dl.Port] {
lsofListeners = append(lsofListeners, dl)
covered[dl.Port] = true
}
}

return lsofListeners
}

// parseLsofListeners parses `lsof -Fpcn` output, deduping per (pid,port).
func parseLsofListeners(out string, rng PortRange) []listener {
var res []listener
Expand Down
Loading