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
33 changes: 32 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
2 changes: 2 additions & 0 deletions docs/https.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 27 additions & 9 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -176,22 +178,22 @@ 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 {
_ = d.dns.Shutdown()
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)
Expand Down Expand Up @@ -518,7 +520,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())
}
Expand Down Expand Up @@ -807,12 +817,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()
Expand All @@ -825,14 +835,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.
Expand All @@ -847,6 +863,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,
Expand Down Expand Up @@ -1002,6 +1019,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)
}
Expand Down
38 changes: 38 additions & 0 deletions internal/daemon/daemon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -202,6 +203,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()})
Expand Down
47 changes: 47 additions & 0 deletions internal/daemon/listen.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions internal/daemon/listen_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions internal/daemon/wildcard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading