diff --git a/docs/configuration.md b/docs/configuration.md index dc719c23..a1ce018e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -33,7 +33,8 @@ Global settings live in `config.json` in the data directory. Edit them through t - the default PHP and Node versions, - the release channel (stable, beta), and the Xdebug and dump capture switches, - parked folders and linked sites, with their PHP and Node versions, HTTPS state and extra domains, -- the ports Flock uses. +- the ports Flock uses, +- the addresses, besides 127.0.0.1, that the sites and DNS also listen on for other machines (`listen`, see [below](#working-from-another-machine)). ## Ports @@ -53,6 +54,36 @@ Each service's port can be changed when you install it, or later (see [Databases **Settings → General** shows the domain under **Network**. ::: +## Working from another machine + +Flock serves its sites and its `.test` DNS on 127.0.0.1 only. To open the sites from another machine, for example a laptop that reaches a development box over [Tailscale](https://tailscale.com) or another trusted network, list the box's address on that network under `listen` in `config.json`: + +```json +{ "listen": ["100.94.14.19"] } +``` + +Then run `flock restart`, or restart Flock from the app. The web server and the DNS server also listen on that address, and DNS answers lookups that arrive there with that address, so the other machine connects back to it. `flock status` shows the address under `listen`. + +Requests that arrive on a `listen` address are served to whoever sends them. List only addresses on networks you trust, such as the box's tailnet address, never one on a public or shared network. Your databases and other services keep listening on 127.0.0.1 only. + +Only IPv4 addresses of the machine itself are used. Flock warns about any other entry in `flock status` and keeps the rest working. If the address isn't there yet, because the VPN connects after Flock starts, Flock reports itself degraded and starts listening on the address once it appears. + +On the other machine: + +- **Send `.test` lookups to the box.** On macOS, create `/etc/resolver/test` with these lines: + + ``` + nameserver 100.94.14.19 + port 53535 + ``` + + Elsewhere, add the sites' names to the hosts file instead (`100.94.14.19 acme.test`), one line per name. When Flock runs on Windows, its DNS port is 53 instead of 53535. +- **Trust Flock's certificate authority** so HTTPS works. Copy `certs/ca.crt` from the box's [data directory](#data-directory) and add it to the other machine's trust store. On macOS that's `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crt`, on Windows `certutil -addstore Root ca.crt` in an administrator prompt. The certificate only covers your TLD and `localhost` (see [HTTPS](https.md#the-local-certificate-authority)). + +:::app +`listen` isn't in the app yet. Edit `config.json` as above, then restart Flock. +::: + ## php.ini Each PHP version has its own `php.ini`, and Flock never overwrites your changes. See [PHP](php.md#phpini). diff --git a/docs/https.md b/docs/https.md index ad588a03..55d86890 100644 --- a/docs/https.md +++ b/docs/https.md @@ -34,6 +34,8 @@ When you install Flock, it creates its own certificate authority (CA) and adds i The CA's root certificate is **name-constrained** to your local TLD and `localhost`. Even if its private key ever leaked, it could not be used to issue certificates for real websites. +To open your sites over HTTPS from another machine, that machine has to trust the CA too (see [Working from another machine](configuration.md#working-from-another-machine)). + Each secured site gets a certificate for `acme.test` and `*.acme.test`. Certificates for deeper subdomains and for the site's aliases are issued on demand the first time they're requested. ## Outgoing HTTPS from PHP diff --git a/internal/api/api.go b/internal/api/api.go index 87f21899..bfc59560 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,6 +35,7 @@ type Status struct { DNS DNSStatus `json:"dns"` HTTPPort int `json:"http_port"` HTTPSPort int `json:"https_port"` + Listen []string `json:"listen,omitempty"` // addresses besides 127.0.0.1 the sites and DNS answer other machines on Mail string `json:"mail"` Dumps bool `json:"dumps"` CaptureOff []string `json:"capture_off"` // event kinds not captured (see config.Config.CaptureOff) diff --git a/internal/cli/status.go b/internal/cli/status.go index 113c816b..48794817 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -41,6 +41,9 @@ func newStatusCommand() *cobra.Command { fmt.Fprintf(out, "dns *.%s doesn't resolve to Flock: %s\n", st.DNS.TLD, st.DNS.WildcardProblem) } fmt.Fprintf(out, "web http :%d, https :%d\n", st.HTTPPort, st.HTTPSPort) + if len(st.Listen) > 0 { + fmt.Fprintf(out, "listen %s (sites and DNS, for other machines)\n", strings.Join(st.Listen, ", ")) + } fmt.Fprintf(out, "mail smtp://%s\n", st.Mail) if st.Postgres != "" { fmt.Fprintf(out, "postgres %s\n", st.Postgres) diff --git a/internal/config/config.go b/internal/config/config.go index e730a0ab..adc31d6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,10 @@ type Config struct { DefaultNode string `json:"default_node"` Ports Ports `json:"ports"` Services Services `json:"services"` + // Listen are addresses of this machine, besides 127.0.0.1, that the sites and the DNS server + // also listen on, for other machines: its tailnet address, say, to work on it from a laptop. + // Requests arriving there are served whoever sends them, so list only trusted networks' addresses. + Listen []string `json:"listen,omitempty"` // Xdebug routes requests that ask for Xdebug (browser extension, XDEBUG_TRIGGER) to a PHP pool // that loads it; XdebugBreakpoints also routes sites with PhpStorm breakpoints there. The key // is new: the old "xdebug" loaded Xdebug for every request and defaulted to off. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a4d76a64..17628bfc 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "sync" "sync/atomic" @@ -67,6 +68,7 @@ type daemon struct { reloadMu sync.Mutex mutateMu sync.Mutex dns *dns.Server // written under reloadMu and mu + listen []string // the listen addresses the last reload served on (see listenAddresses) dnsErr error // why the DNS server isn't running (Windows, where it is optional) mail *mail.Server // collector receives the PHP agent's dumps and events while capture is on. @@ -176,14 +178,14 @@ var windowsDNSPort = dns.Port // ensureDNS (re)starts the DNS server when its address or TLD changed. On Windows it is optional: // the hosts file still resolves every site's domains when port 53 is taken, so a failure there is // reported through the wildcard check instead of as a warning. -func (d *daemon) ensureDNS(cfg *config.Config) error { +func (d *daemon) ensureDNS(cfg *config.Config, also []string) error { port := cfg.Ports.DNS if runtime.GOOS == "windows" { port = windowsDNSPort } addr := fmt.Sprintf("127.0.0.1:%d", port) - if d.dns != nil && d.dns.Addr() == addr && d.dns.TLD() == cfg.TLD { + if d.dns != nil && d.dns.Addr() == addr && d.dns.TLD() == cfg.TLD && slices.Equal(d.dns.Also(), also) { return nil } if d.dns != nil { @@ -191,7 +193,7 @@ func (d *daemon) ensureDNS(cfg *config.Config) error { d.setDNS(nil, nil) } - srv := dns.New(addr, cfg.TLD) + srv := dns.New(addr, cfg.TLD, also...) if err := srv.Start(); err != nil { err = fmt.Errorf("dns: %s", busy(port, err)) d.setDNS(nil, err) @@ -505,7 +507,15 @@ func (d *daemon) reload() error { if err := trustInBrowsers(ca.CertPath()); err != nil { warnings = append(warnings, "browsers don't trust Flock's HTTPS certificates: "+err.Error()) } - for _, ensure := range []func(*config.Config) error{d.ensureDNS, d.ensureMail, d.ensureCollector} { + // One reading of the machine's addresses for DNS, web and the warnings. Reading them for each + // could see a VPN address appear in between: web would serve it, DNS wouldn't, and with no + // warning the daemon wouldn't retry. + listen, listenProblems := listenAddresses(cfg) + warnings = append(warnings, listenProblems...) + if err := d.ensureDNS(cfg, listen); err != nil { + warnings = append(warnings, err.Error()) + } + for _, ensure := range []func(*config.Config) error{d.ensureMail, d.ensureCollector} { if err := ensure(cfg); err != nil { warnings = append(warnings, err.Error()) } @@ -791,12 +801,12 @@ func (d *daemon) reload() error { bind = "" } - if err := d.applyWeb(cfg, bind, webSites, waiting, rootless, shares, ca); err != nil { + if err := d.applyWeb(cfg, bind, listen, webSites, waiting, rootless, shares, ca); err != nil { warnings = append(warnings, "web: "+err.Error()) } d.mu.Lock() - d.cfg, d.sites, d.warnings, d.problems, d.breakpointSites = cfg, statuses, warnings, problems, breakpoints + d.cfg, d.sites, d.warnings, d.problems, d.breakpointSites, d.listen = cfg, statuses, warnings, problems, breakpoints, listen d.mu.Unlock() go d.checkWildcard() @@ -809,14 +819,20 @@ func (d *daemon) reload() error { // applyWeb (re)configures Caddy. Before the first successful start it checks the ports itself, // because Caddy's own error does not say who holds them. -func (d *daemon) applyWeb(cfg *config.Config, bind string, sites []web.Site, waiting []web.Waiting, rootless []web.Rootless, shares []web.Share, ca *certs.CA) error { +func (d *daemon) applyWeb(cfg *config.Config, bind string, listen []string, sites []web.Site, waiting []web.Waiting, rootless []web.Rootless, shares []web.Share, ca *certs.CA) error { owned := map[int]bool{} if d.webUp && d.cfg != nil { owned[d.cfg.Ports.HTTP], owned[d.cfg.Ports.HTTPS] = true, true } + hosts := []string{bind} + if bind != "" { + hosts = append(hosts, listen...) + } for _, port := range []int{cfg.Ports.HTTP, cfg.Ports.HTTPS} { - if !owned[port] && !ports.Free(bind, port) { - return errors.New(ports.Describe(port)) + for _, host := range hosts { + if !owned[port] && !ports.Free(host, port) { + return errors.New(ports.Describe(port)) + } } // On Linux the service gives the daemon the capability for ports below 1024; one started // some other way (`flock daemon` in a terminal) lacks it. @@ -831,6 +847,7 @@ func (d *daemon) applyWeb(cfg *config.Config, bind string, sites []web.Site, wai HTTPPort: cfg.Ports.HTTP, HTTPSPort: cfg.Ports.HTTPS, Bind: bind, + Listen: listen, Sites: sites, Waiting: waiting, Rootless: rootless, @@ -974,6 +991,7 @@ func (d *daemon) Status() api.Status { } if d.cfg != nil { st.HTTPPort, st.HTTPSPort = d.cfg.Ports.HTTP, d.cfg.Ports.HTTPS + st.Listen = d.listen if d.cfg.Services.Postgres != "" { st.Postgres = fmt.Sprintf("%s on 127.0.0.1:%d", postgres.VersionOf(d.cfg.Services.Postgres), d.cfg.Ports.Postgres) } diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 2da28704..3710cdf6 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -161,6 +162,43 @@ func TestReloadReportsSitesWithMissingPHP(t *testing.T) { } } +func TestReloadReadsTheMachinesAddressesOnce(t *testing.T) { + var reads atomic.Int32 + old := interfaceAddrs + interfaceAddrs = func() ([]net.Addr, error) { + reads.Add(1) + return []net.Addr{&net.IPNet{IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32)}}, nil + } + t.Cleanup(func() { interfaceAddrs = old }) + + root, _ := testRoot(t, func(c *config.Config) { c.Listen = []string{"100.64.0.9"} }) + client := startDaemon(t, root) + + before := reads.Load() + status, err := client.Reload(context.Background()) + if err != nil { + t.Fatal(err) + } + // DNS, web, the warnings and the status come from one reading: read separately, a VPN address + // appearing in between was served by web but not DNS, with no warning to make the daemon retry. + if n := reads.Load() - before; n != 1 { + t.Errorf("a reload read the addresses %d times, want 1", n) + } + if n := countContaining(status.Warnings, "100.64.0.9 isn't an address of this machine"); n != 1 { + t.Errorf("warning reported %d times: %q", n, status.Warnings) + } +} + +func countContaining(list []string, part string) int { + n := 0 + for _, s := range list { + if strings.Contains(s, part) { + n++ + } + } + return n +} + func TestTheFirstRunWaitsForTheDefaultsAndSaysSo(t *testing.T) { root, cfg := testRoot(t, func(cfg *config.Config) { cfg.DefaultPHP, cfg.DefaultNode = "", "" }) cfg.Upsert(config.Site{Name: "app", Path: t.TempDir()}) diff --git a/internal/daemon/listen.go b/internal/daemon/listen.go new file mode 100644 index 00000000..592b1f41 --- /dev/null +++ b/internal/daemon/listen.go @@ -0,0 +1,47 @@ +package daemon + +import ( + "fmt" + "net" + + "flock/internal/config" +) + +// interfaceAddrs lists this machine's addresses. Tests replace it. +var interfaceAddrs = net.InterfaceAddrs + +// listenAddresses are the config's listen addresses the web and DNS servers can use now: IPv4 +// addresses of this machine, each once. The others are problems to report; one that isn't there +// yet (a VPN that connects later) is picked up by the retries of a degraded daemon. +func listenAddresses(cfg *config.Config) (hosts, problems []string) { + if len(cfg.Listen) == 0 { + return nil, nil + } + own := map[string]bool{} + if addrs, err := interfaceAddrs(); err == nil { + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok { + own[n.IP.String()] = true + } + } + } + seen := map[string]bool{} + for _, l := range cfg.Listen { + ip := net.ParseIP(l) + switch { + case ip == nil || ip.To4() == nil: + problems = append(problems, fmt.Sprintf("listen: %q isn't an IPv4 address", l)) + case ip.IsLoopback() || ip.IsUnspecified(): + problems = append(problems, fmt.Sprintf("listen: %s isn't an address other machines reach this one on (its tailnet address, say)", l)) + case !own[ip.String()]: + problems = append(problems, fmt.Sprintf("listen: %s isn't an address of this machine (yet)", l)) + case seen[ip.String()]: + // The DNS server would bind it twice, and the failed second bind takes down every + // listener, loopback included. + default: + seen[ip.String()] = true + hosts = append(hosts, ip.String()) + } + } + return hosts, problems +} diff --git a/internal/daemon/listen_test.go b/internal/daemon/listen_test.go new file mode 100644 index 00000000..68b58cbb --- /dev/null +++ b/internal/daemon/listen_test.go @@ -0,0 +1,49 @@ +package daemon + +import ( + "net" + "slices" + "strings" + "testing" + + "flock/internal/config" +) + +func TestListenAddressesKeepsThisMachinesIPv4Addresses(t *testing.T) { + old := interfaceAddrs + interfaceAddrs = func() ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32)}, + &net.IPNet{IP: net.ParseIP("100.64.0.7"), Mask: net.CIDRMask(32, 32)}, + }, nil + } + t.Cleanup(func() { interfaceAddrs = old }) + + if hosts, problems := listenAddresses(&config.Config{}); hosts != nil || problems != nil { + t.Fatalf("no listen addresses: %v, %v", hosts, problems) + } + + hosts, problems := listenAddresses(&config.Config{Listen: []string{"100.64.0.7", "100.64.0.8", "127.0.0.1", "0.0.0.0", "fd7a::1", "tailnet"}}) + if !slices.Equal(hosts, []string{"100.64.0.7"}) { + t.Errorf("hosts %v, want the tailnet address only", hosts) + } + for _, want := range []string{"100.64.0.8 isn't an address of this machine", "127.0.0.1 isn't an address other machines reach", "0.0.0.0 isn't an address other machines reach", `"fd7a::1" isn't an IPv4 address`, `"tailnet" isn't an IPv4 address`} { + if !slices.ContainsFunc(problems, func(p string) bool { return strings.Contains(p, want) }) { + t.Errorf("no problem %q in %q", want, problems) + } + } +} + +func TestListenAddressesListsEachAddressOnce(t *testing.T) { + old := interfaceAddrs + interfaceAddrs = func() ([]net.Addr, error) { + return []net.Addr{&net.IPNet{IP: net.ParseIP("100.64.0.7"), Mask: net.CIDRMask(32, 32)}}, nil + } + t.Cleanup(func() { interfaceAddrs = old }) + + // The DNS server would bind a repeated address twice and lose every listener on the failure. + hosts, problems := listenAddresses(&config.Config{Listen: []string{"100.64.0.7", "::ffff:100.64.0.7", "100.64.0.7"}}) + if !slices.Equal(hosts, []string{"100.64.0.7"}) || problems != nil { + t.Fatalf("hosts %v, problems %v", hosts, problems) + } +} diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index 98799afc..490cd787 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -25,11 +25,11 @@ type wildcardState struct { // recheckWildcard retries a DNS server that couldn't start (Windows: port 53 was taken) and checks again. func (d *daemon) recheckWildcard() { d.mu.Lock() - cfg, down := d.cfg, d.dns == nil + cfg, down, listen := d.cfg, d.dns == nil, d.listen d.mu.Unlock() if cfg != nil && down && runtime.GOOS == "windows" { d.reloadMu.Lock() - _ = d.ensureDNS(cfg) + _ = d.ensureDNS(cfg, listen) d.reloadMu.Unlock() } d.checkWildcard() diff --git a/internal/dns/server.go b/internal/dns/server.go index 5197547f..d91279a2 100644 --- a/internal/dns/server.go +++ b/internal/dns/server.go @@ -1,4 +1,5 @@ -// Package dns answers every query under the local TLD (e.g. *.test) with loopback. +// Package dns answers every query under the local TLD (e.g. *.test) with loopback, or with the +// address it reached when that's one other machines use (config's listen addresses). // // On macOS it is wired up via /etc/resolver/ with a non-privileged port, so it never needs // root. On Windows it listens on port 53 (Windows can't send queries to another port) and an NRPT @@ -18,42 +19,73 @@ const ttl = 60 // Server is a tiny authoritative DNS server for a single local TLD. type Server struct { - tld string - addr string - udp *mdns.Server - tcp *mdns.Server + tld string + addr string + also []string + servers []*mdns.Server mu sync.Mutex watched map[string]bool // name → queried since Watch } -// New creates a server for tld (without dots, e.g. "test") listening on addr (e.g. "127.0.0.1:53535"). -func New(addr, tld string) *Server { - return &Server{tld: strings.Trim(strings.ToLower(tld), "."), addr: addr, watched: map[string]bool{}} +// New creates a server for tld (without dots, e.g. "test") listening on addr (e.g. "127.0.0.1:53535"), +// and on the same port of every host in also: addresses of this machine that other machines reach +// it on, such as a tailnet address. +func New(addr, tld string, also ...string) *Server { + return &Server{tld: strings.Trim(strings.ToLower(tld), "."), addr: addr, also: also, watched: map[string]bool{}} } -// Start binds UDP and TCP on the same port and serves in the background. +// Start binds UDP and TCP on the same port of every address and serves in the background. func (s *Server) Start() error { - pc, err := net.ListenPacket("udp", s.addr) + type listener struct { + pc net.PacketConn + ln net.Listener + } + pc, ln, err := listen(s.addr) + if err != nil { + return err + } + listeners := []listener{{pc, ln}} + _, port, _ := net.SplitHostPort(pc.LocalAddr().String()) + for _, host := range s.also { + pc, ln, err := listen(net.JoinHostPort(host, port)) + if err != nil { + for _, l := range listeners { + _, _ = l.pc.Close(), l.ln.Close() + } + return err + } + listeners = append(listeners, listener{pc, ln}) + } + + s.addr = pc.LocalAddr().String() + for _, l := range listeners { + s.serve(l.pc, l.ln) + } + return nil +} + +func listen(addr string) (net.PacketConn, net.Listener, error) { + pc, err := net.ListenPacket("udp", addr) if err != nil { - return fmt.Errorf("dns udp listen: %w", err) + return nil, nil, fmt.Errorf("dns udp listen: %w", err) } // Reuse the port UDP actually got, so ":0" works in tests. ln, err := net.Listen("tcp", pc.LocalAddr().String()) if err != nil { _ = pc.Close() - return fmt.Errorf("dns tcp listen: %w", err) + return nil, nil, fmt.Errorf("dns tcp listen: %w", err) } + return pc, ln, nil +} - s.addr = pc.LocalAddr().String() - s.udp = &mdns.Server{PacketConn: pc, Handler: mdns.HandlerFunc(s.handle)} - s.tcp = &mdns.Server{Listener: ln, Handler: mdns.HandlerFunc(s.handle)} - - go func() { _ = s.udp.ActivateAndServe() }() - go func() { _ = s.tcp.ActivateAndServe() }() - - return nil +func (s *Server) serve(pc net.PacketConn, ln net.Listener) { + udp := &mdns.Server{PacketConn: pc, Handler: mdns.HandlerFunc(s.handle)} + tcp := &mdns.Server{Listener: ln, Handler: mdns.HandlerFunc(s.handle)} + s.servers = append(s.servers, udp, tcp) + go func() { _ = udp.ActivateAndServe() }() + go func() { _ = tcp.ActivateAndServe() }() } // Addr is the bound address (resolved after Start). @@ -66,6 +98,11 @@ func (s *Server) TLD() string { return s.tld } +// Also is the further hosts the server listens on (see New). +func (s *Server) Also() []string { + return s.also +} + // Watch starts recording queries for name and returns a function reporting whether one arrived. // The daemon uses it to check that the system resolver really sends the TLD here. func (s *Server) Watch(name string) (seen func() bool) { @@ -83,13 +120,10 @@ func (s *Server) Watch(name string) (seen func() bool) { } } -// Shutdown stops both listeners. +// Shutdown stops every listener. func (s *Server) Shutdown() error { var firstErr error - for _, srv := range []*mdns.Server{s.udp, s.tcp} { - if srv == nil { - continue - } + for _, srv := range s.servers { if err := srv.Shutdown(); err != nil && firstErr == nil { firstErr = err } @@ -98,9 +132,10 @@ func (s *Server) Shutdown() error { return firstErr } -// handle answers A queries with 127.0.0.1 and everything else (AAAA included) with no records. -// Sites listen on 127.0.0.1 only (on Windows, and on macOS with unprivileged ports), so answering -// AAAA with ::1 makes clients try IPv6 first: PHP's streams then wait ~2 s for each connection. +// handle answers A queries with the address they reached (see answer) and everything else (AAAA +// included) with no records. Sites listen on 127.0.0.1 only (on Windows, and on macOS with +// unprivileged ports), so answering AAAA with ::1 makes clients try IPv6 first: PHP's streams then +// wait ~2 s for each connection. func (s *Server) handle(w mdns.ResponseWriter, req *mdns.Msg) { resp := new(mdns.Msg) resp.SetReply(req) @@ -115,13 +150,29 @@ func (s *Server) handle(w mdns.ResponseWriter, req *mdns.Msg) { if q.Qtype == mdns.TypeA { hdr := mdns.RR_Header{Name: q.Name, Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: ttl} - resp.Answer = append(resp.Answer, &mdns.A{Hdr: hdr, A: net.IPv4(127, 0, 0, 1)}) + resp.Answer = append(resp.Answer, &mdns.A{Hdr: hdr, A: answer(w.LocalAddr())}) } } _ = w.WriteMsg(resp) } +// answer is what a query that reached local is answered with: the address it reached, so another +// machine querying a tailnet address gets that address, and loopback otherwise. +func answer(local net.Addr) net.IP { + var ip net.IP + switch a := local.(type) { + case *net.UDPAddr: + ip = a.IP + case *net.TCPAddr: + ip = a.IP + } + if ip4 := ip.To4(); ip4 != nil && !ip4.IsUnspecified() { + return ip4 + } + return net.IPv4(127, 0, 0, 1) +} + func (s *Server) saw(name string) { name = strings.TrimSuffix(strings.ToLower(name), ".") s.mu.Lock() diff --git a/internal/dns/server_test.go b/internal/dns/server_test.go index 46556f6b..41b09a07 100644 --- a/internal/dns/server_test.go +++ b/internal/dns/server_test.go @@ -2,6 +2,7 @@ package dns import ( "net" + "runtime" "testing" mdns "github.com/miekg/dns" @@ -93,3 +94,31 @@ func TestRefusesNamesOutsideTheTLD(t *testing.T) { } } } + +// A query reaching one of the further addresses (another machine's way in, e.g. over a tailnet) is +// answered with that address, one on loopback still with loopback. +func TestAnswersWithTheAddressTheQueryReached(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("needs a second loopback address (127.0.0.2), which only Linux has without setup") + } + srv := New("127.0.0.1:0", "test", "127.0.0.2") + if err := srv.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = srv.Shutdown() }) + _, port, _ := net.SplitHostPort(srv.Addr()) + + for _, host := range []string{"127.0.0.1", "127.0.0.2"} { + for _, network := range []string{"udp", "tcp"} { + msg := new(mdns.Msg) + msg.SetQuestion("app.test.", mdns.TypeA) + resp, _, err := (&mdns.Client{Net: network}).Exchange(msg, net.JoinHostPort(host, port)) + if err != nil { + t.Fatalf("%s over %s: %v", host, network, err) + } + if len(resp.Answer) != 1 || resp.Answer[0].(*mdns.A).A.String() != host { + t.Fatalf("%s over %s: answered %v", host, network, resp.Answer) + } + } + } +} diff --git a/internal/web/bind.go b/internal/web/bind.go index 02c190fa..2bdf9f8c 100644 --- a/internal/web/bind.go +++ b/internal/web/bind.go @@ -4,10 +4,19 @@ import ( "encoding/json" ) -// withBind rewrites every server's listen addresses (":80") to host-qualified ones ("127.0.0.1:80"). -// The Caddyfile `bind` directive would do this per site, but the catch-all needs it too. -func withBind(cfgJSON []byte, host string) []byte { - if host == "" { +// binds is every host the servers listen on: Bind and the Listen addresses, or none (every +// address) when Bind is empty. +func binds(cfg Config) []string { + if cfg.Bind == "" { + return nil + } + return append([]string{cfg.Bind}, cfg.Listen...) +} + +// withBind rewrites every server's listen addresses (":80") to host-qualified ones ("127.0.0.1:80"), +// one per host. The Caddyfile `bind` directive would do this per site, but the catch-all needs it too. +func withBind(cfgJSON []byte, hosts []string) []byte { + if len(hosts) == 0 { return cfgJSON } @@ -22,11 +31,18 @@ func withBind(cfgJSON []byte, host string) []byte { for _, s := range servers { srv, _ := s.(map[string]any) listen, _ := srv["listen"].([]any) - for i, l := range listen { - if addr, ok := l.(string); ok && len(addr) > 0 && addr[0] == ':' { - listen[i] = host + addr + var bound []any + for _, l := range listen { + addr, ok := l.(string) + if !ok || len(addr) == 0 || addr[0] != ':' { + bound = append(bound, l) + continue + } + for _, host := range hosts { + bound = append(bound, host+addr) } } + srv["listen"] = bound } out, err := json.Marshal(cfg) diff --git a/internal/web/certsource.go b/internal/web/certsource.go index ccf1827a..6c016305 100644 --- a/internal/web/certsource.go +++ b/internal/web/certsource.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "fmt" "net" + "slices" "strings" "sync/atomic" @@ -25,12 +26,13 @@ func init() { // that name, which is how a.b.app.test, aliases and the HTTPS catch-all get theirs. type certSource struct { tld string + listen []string static []*tls.Certificate issue func(name string) (*tls.Certificate, error) } func newCertSource(cfg Config) (*certSource, error) { - s := &certSource{tld: strings.ToLower(cfg.TLD), issue: cfg.Issue} + s := &certSource{tld: strings.ToLower(cfg.TLD), listen: cfg.Listen, issue: cfg.Issue} for _, site := range cfg.Sites { if site.CertFile == "" { continue @@ -54,8 +56,9 @@ func (s *certSource) certificate(hello *tls.ClientHelloInfo) (*tls.Certificate, switch { case s == nil: return nil, fmt.Errorf("no certificates configured") - case hello.Conn != nil && !loopback(hello.Conn.RemoteAddr()): - // Remote clients are refused per request anyway; don't issue certificates for them. + case hello.Conn != nil && !loopback(hello.Conn.RemoteAddr()) && !s.listens(hello.Conn.LocalAddr()): + // Remote clients are refused per request anyway, unless they came in on a Listen address; + // don't issue certificates for them. return nil, fmt.Errorf("not a local client") case name == "" || (name != s.tld && !strings.HasSuffix(name, "."+s.tld)): return nil, fmt.Errorf("no certificate for %q: only *.%s is served", name, s.tld) @@ -80,6 +83,12 @@ func loopback(addr net.Addr) bool { return tcp.IP.IsLoopback() } +// listens reports whether addr is one of the Listen addresses, where other machines are served. +func (s *certSource) listens(addr net.Addr) bool { + tcp, ok := addr.(*net.TCPAddr) + return ok && slices.Contains(s.listen, tcp.IP.String()) +} + // certModule is Caddy's `get_certificate flock`. type certModule struct{} diff --git a/internal/web/web.go b/internal/web/web.go index fc3c23bc..e32c005b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -83,7 +83,10 @@ type Config struct { HTTPSPort int // Bind is the listen host. macOS only lets non-root processes bind ports below 1024 // on the wildcard address, so it is empty there and non-local clients are rejected per request. - Bind string + Bind string + // Listen are further addresses of this machine that other machines reach the sites on (e.g. a + // tailnet address): the server listens there too, and requests arriving there aren't rejected. + Listen []string Sites []Site Waiting []Waiting Rootless []Rootless @@ -125,11 +128,13 @@ func Caddyfile(cfg Config) string { } (flock_site) { - @remote not client_ip 127.0.0.0/8 ::1 + @remote { + not client_ip 127.0.0.0/8 ::1 +%s } abort @remote encode zstd gzip } -`, cfg.HTTPPort, cfg.HTTPSPort, quote(cfg.StorageDir), quote(cfg.LogFile)) +`, cfg.HTTPPort, cfg.HTTPSPort, quote(cfg.StorageDir), quote(cfg.LogFile), listenExempt(cfg)) // Every certificate comes from Flock (see certSource): the site's own file, or one issued for the name. const tlsBlock = "\ttls {\n\t\tget_certificate flock\n\t}\n" @@ -189,6 +194,15 @@ func Caddyfile(cfg Config) string { return b.String() } +// listenExempt lets requests that arrived on one of the Listen addresses through, from whichever +// client reaches them. +func listenExempt(cfg Config) string { + if len(cfg.Listen) == 0 { + return "" + } + return "\t\tnot vars {http.request.local.host} " + strings.Join(cfg.Listen, " ") + "\n" +} + // rootlessVars sets {http.vars.flock_root_*} for a site without a usable document root: the site's // name, why, the root and whether it's set for the site, the file that sets it, and the suggested // roots (separated by |). @@ -330,7 +344,7 @@ func Apply(cfg Config) error { } } - json = withBind(json, cfg.Bind) + json = withBind(json, binds(cfg)) source, err := newCertSource(cfg) if err != nil { diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 7e6ca184..6b4a4eb6 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1,6 +1,8 @@ package web import ( + "crypto/tls" + "net" "strings" "testing" @@ -51,11 +53,36 @@ func TestCaddyfileRoutesSitesAndSubdomains(t *testing.T) { func TestWithBindQualifiesListenAddresses(t *testing.T) { in := []byte(`{"apps":{"http":{"servers":{"srv0":{"listen":[":443"]}}}}}`) - if got := string(withBind(in, "127.0.0.1")); !strings.Contains(got, `"127.0.0.1:443"`) { + if got := string(withBind(in, []string{"127.0.0.1"})); !strings.Contains(got, `"listen":["127.0.0.1:443"]`) { t.Fatalf("got %s", got) } - if got := string(withBind(in, "")); got != string(in) { - t.Fatalf("empty bind must not change config, got %s", got) + if got := string(withBind(in, []string{"127.0.0.1", "100.64.0.7"})); !strings.Contains(got, `"listen":["127.0.0.1:443","100.64.0.7:443"]`) { + t.Fatalf("with a Listen address: got %s", got) + } + if got := string(withBind(in, nil)); got != string(in) { + t.Fatalf("no bind must not change config, got %s", got) + } + if got := binds(Config{Listen: []string{"100.64.0.7"}}); got != nil { + t.Fatalf("binds without Bind: %v, want every address (nil)", got) + } +} + +// Requests reaching a Listen address are served whichever client sent them; elsewhere only +// loopback clients are. +func TestCaddyfileLetsRequestsOnListenAddressesThrough(t *testing.T) { + cfg := testConfig() + cfg.Listen = []string{"100.64.0.7", "192.168.1.5"} + cf := Caddyfile(cfg) + for _, want := range []string{"not client_ip 127.0.0.0/8 ::1", "not vars {http.request.local.host} 100.64.0.7 192.168.1.5"} { + if !strings.Contains(cf, want) { + t.Errorf("missing %q in:\n%s", want, cf) + } + } + if _, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil); err != nil { + t.Fatalf("adapt: %v", err) + } + if cf := Caddyfile(testConfig()); strings.Contains(cf, "http.request.local.host") { + t.Errorf("no Listen addresses, but the exemption is there:\n%s", cf) } } @@ -130,3 +157,31 @@ func TestCaddyfileProxiesFrankenPHPSitesButNotTheirXdebugRequests(t *testing.T) } } } + +// addrConn is a connection with just the addresses a TLS handshake looks at. +type addrConn struct { + net.Conn + local, remote net.Addr +} + +func (c addrConn) LocalAddr() net.Addr { return c.local } +func (c addrConn) RemoteAddr() net.Addr { return c.remote } + +// Another machine gets a certificate only when it came in on a Listen address. +func TestCertificatesForOtherMachinesOnlyOnListenAddresses(t *testing.T) { + s := &certSource{tld: "test", listen: []string{"100.64.0.7"}, issue: func(string) (*tls.Certificate, error) { return &tls.Certificate{}, nil }} + for _, c := range []struct { + local, remote string + ok bool + }{ + {"127.0.0.1", "127.0.0.1", true}, + {"100.64.0.7", "100.64.0.9", true}, + {"192.168.1.5", "192.168.1.9", false}, // the wildcard bind of macOS, reached on another address + } { + conn := addrConn{local: &net.TCPAddr{IP: net.ParseIP(c.local), Port: 443}, remote: &net.TCPAddr{IP: net.ParseIP(c.remote), Port: 50000}} + _, err := s.certificate(&tls.ClientHelloInfo{ServerName: "app.test", Conn: conn}) + if (err == nil) != c.ok { + t.Errorf("%s from %s: err %v, want ok %v", c.local, c.remote, err, c.ok) + } + } +}