Skip to content
Merged
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
1 change: 1 addition & 0 deletions beacon.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[server]
host = "127.0.0.1"
port = 4600
allow_non_loopback = false

[database]
addrs = ["127.0.0.1:9000"]
Expand Down
31 changes: 30 additions & 1 deletion docs/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Beacon can run without a config file. For persistent settings, create
[server]
host = "127.0.0.1"
port = 4600
allow_non_loopback = false

[database]
addrs = ["127.0.0.1:9000"]
Expand Down Expand Up @@ -88,6 +89,7 @@ Keep Beacon itself bound to loopback:
[server]
host = "127.0.0.1"
port = 4600
allow_non_loopback = false
```

Configure the proxy upstream to forward to `127.0.0.1:4600` while preserving the
Expand All @@ -97,6 +99,30 @@ DELETE requests. Terminate TLS and authentication in the proxy or VPN layer.
Beacon's dashboard, JSON API, and `/api/mcp` trust the local network boundary
once the request reaches the process.

## Direct Private Network Bind

For a trusted private network such as a Tailnet, Beacon can bind directly to a
non-loopback interface when you opt in:

```toml
[server]
host = "100.64.0.1" # replace with this machine's Tailscale/private IP
port = 4600
allow_non_loopback = true
```

Then open the dashboard through the machine's private address, for example:

```text
http://100.64.0.1:4600
```

You can use `host = "0.0.0.0"` to listen on every IPv4 interface, but that also
exposes Beacon on Wi-Fi, Ethernet, and other reachable interfaces unless the OS,
firewall, or Tailnet ACLs block them. Only use direct binding on a network
boundary you trust. Anyone who can reach the bound interface can read Beacon
dashboard data and invoke JSON API or `/api/mcp` mutation paths.

## ClickHouse

The simplest setup keeps ClickHouse on the same machine as Beacon:
Expand Down Expand Up @@ -249,14 +275,17 @@ you need to reset derived Beacon data, stop Beacon first and back up
If the dashboard is unreachable:

1. Confirm the process is running with `beacon status`.
2. Check that the configured port is listening on loopback.
2. Check that the configured port is listening on the expected interface.
3. Curl the local health endpoint:

```bash
curl -fsS http://localhost:4600/health
```

4. If using a proxy or VPN, confirm the upstream forwards to `127.0.0.1:4600`.
If using direct private-network access, confirm `[server]` sets
`allow_non_loopback = true` and that `lsof` shows Beacon listening on the
expected non-loopback address.
5. Check Beacon logs from your terminal, systemd unit, LaunchAgent, or tmux
session.

Expand Down
7 changes: 4 additions & 3 deletions internal/beaconcli/cmd_doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ func runDoctorSetup(cmd *cobra.Command, args []string) error {

func runLocalSetupDoctor(ctx context.Context, report *doctorReport, cfg *config.Config) {
report.line("INFO", "mode", "single-machine local dashboard")
if checkServer(cfg.Server.Port) {
report.line("PASS", "local health", fmt.Sprintf("http://127.0.0.1:%d/health responded", cfg.Server.Port))
healthURL := serverHealthURL(cfg.Server.Host, cfg.Server.Port)
if checkServer(cfg.Server.Host, cfg.Server.Port) {
report.line("PASS", "local health", fmt.Sprintf("%s responded", healthURL))
} else {
report.line("WARN", "local health", fmt.Sprintf("Beacon is not responding at http://127.0.0.1:%d/health", cfg.Server.Port))
report.line("WARN", "local health", fmt.Sprintf("Beacon is not responding at %s", healthURL))
report.remediation("Start the dashboard with `beacon up`.")
}

Expand Down
33 changes: 29 additions & 4 deletions internal/beaconcli/cmd_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package beaconcli
import (
"database/sql"
"fmt"
"net"
"net/http"
"os"
"strconv"
Expand Down Expand Up @@ -37,7 +38,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
fmt.Println("=============")

addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
serverUp := checkServer(cfg.Server.Port)
serverUp := checkServer(cfg.Server.Host, cfg.Server.Port)
if serverUp {
pid := readPid()
if pid > 0 {
Expand Down Expand Up @@ -126,17 +127,41 @@ func runStatus(cmd *cobra.Command, args []string) error {
return nil
}

// checkServer returns true if the beacon server responds on the given port.
func checkServer(port int) bool {
// checkServer returns true if the beacon server responds on the configured host and port.
func checkServer(host string, port int) bool {
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/health", port))
resp, err := client.Get(serverHealthURL(host, port))
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

func serverHealthURL(host string, port int) string {
probeHost := serverHealthProbeHost(host)
return "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health"
}

func serverHealthProbeHost(host string) string {
probeHost := strings.TrimSpace(host)
if parsedHost, _, err := net.SplitHostPort(probeHost); err == nil {
probeHost = parsedHost
}
probeHost = strings.Trim(probeHost, "[]")
if probeHost == "" {
return "127.0.0.1"
}
ip := net.ParseIP(probeHost)
if ip == nil || !ip.IsUnspecified() {
return probeHost
}
if ip.To4() == nil {
return "::1"
}
return "127.0.0.1"
}

// readPid reads the PID from the pidfile. Returns 0 if not available or stale.
func readPid() int {
data, err := os.ReadFile(pidfilePath())
Expand Down
2 changes: 1 addition & 1 deletion internal/beaconcli/cmd_stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func runStop(cmd *cobra.Command, args []string) error {
}

// No pidfile — try to find PID by port
if checkServer(cfg.Server.Port) {
if checkServer(cfg.Server.Host, cfg.Server.Port) {
pid, err := findPidByPort(cfg.Server.Port)
if err != nil || pid <= 0 {
return fmt.Errorf("server is running on port %d but could not determine PID\nStop it manually with: kill $(lsof -ti :%d)", cfg.Server.Port, cfg.Server.Port)
Expand Down
26 changes: 24 additions & 2 deletions internal/beaconcli/cmd_stop_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,41 @@ func TestCheckServerHealthStatus(t *testing.T) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(okServer.Close)
if !checkServer(serverPort(t, okServer)) {
if !checkServer("127.0.0.1", serverPort(t, okServer)) {
t.Fatal("checkServer() = false, want true for healthy server")
}

errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(errorServer.Close)
if checkServer(serverPort(t, errorServer)) {
if checkServer("127.0.0.1", serverPort(t, errorServer)) {
t.Fatal("checkServer() = true, want false for non-200 health response")
}
}

func TestServerHealthURLUsesConfiguredHost(t *testing.T) {
tests := []struct {
name string
host string
want string
}{
{name: "loopback", host: "127.0.0.1", want: "http://127.0.0.1:4600/health"},
{name: "localhost", host: "localhost", want: "http://localhost:4600/health"},
{name: "tailscale ip", host: "100.88.255.7", want: "http://100.88.255.7:4600/health"},
{name: "wildcard ipv4", host: "0.0.0.0", want: "http://127.0.0.1:4600/health"},
{name: "wildcard ipv6", host: "::", want: "http://[::1]:4600/health"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverHealthURL(tt.host, 4600); got != tt.want {
t.Fatalf("serverHealthURL(%q) = %q, want %q", tt.host, got, tt.want)
}
})
}
}

func TestWaitForExitReturnsTrueForMissingProcess(t *testing.T) {
if !waitForExit(999999999, time.Millisecond) {
t.Fatal("waitForExit() = false, want true for missing process")
Expand Down
41 changes: 40 additions & 1 deletion internal/beaconcli/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,49 @@ addrs = ["127.0.0.1:1"]
if err == nil {
t.Fatal("runServe returned nil error")
}
if !strings.Contains(err.Error(), "server.host") || !strings.Contains(err.Error(), "local dashboards only") {
if !strings.Contains(err.Error(), "server.host") || !strings.Contains(err.Error(), "server.allow_non_loopback") {
t.Fatalf("runServe error = %q, want non-loopback host rejection", err.Error())
}
if strings.Contains(err.Error(), "clickhouse") {
t.Fatalf("runServe error = %q, host rejection should happen before ClickHouse", err.Error())
}
}

func TestRunServeAllowsNonLoopbackHostWithExplicitOptIn(t *testing.T) {
resetConfigState(t)
cfgPath := t.TempDir() + "/beacon.toml"
if err := os.WriteFile(cfgPath, []byte(`
[server]
host = "0.0.0.0"
allow_non_loopback = true

[database]
addrs = ["127.0.0.1:1"]
`), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
cfgFile = cfgPath
lock, err := acquireBeaconRunLock()
if err != nil {
t.Fatalf("acquire run lock: %v", err)
}
t.Cleanup(func() {
if err := lock.Close(); err != nil {
t.Fatalf("close run lock: %v", err)
}
})

err = runServe(newUpCmd(), nil)
if err == nil {
t.Fatal("runServe returned nil error")
}
if !strings.Contains(err.Error(), "acquire beacon pidfile") || !strings.Contains(err.Error(), "locked") {
t.Fatalf("runServe error = %q, want pidfile lock rejection", err.Error())
}
if strings.Contains(err.Error(), "server.host") {
t.Fatalf("runServe error = %q, host validation should allow explicit non-loopback opt-in", err.Error())
}
if strings.Contains(err.Error(), "clickhouse") {
t.Fatalf("runServe error = %q, pidfile rejection should happen before ClickHouse", err.Error())
}
}
10 changes: 6 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ type Config struct {
}

type ServerConfig struct {
Host string
Port int
Host string
Port int
AllowNonLoopback bool `mapstructure:"allow_non_loopback"`
}

type DatabaseConfig struct {
Expand Down Expand Up @@ -138,6 +139,7 @@ func Load(cfgFile string) (*Config, error) {
func setDefaults(v *viper.Viper) {
v.SetDefault("server.host", "127.0.0.1")
v.SetDefault("server.port", 4600)
v.SetDefault("server.allow_non_loopback", false)
v.SetDefault("database.addrs", []string{"127.0.0.1:9000"})
v.SetDefault("database.database", "beacon")
v.SetDefault("database.username", "default")
Expand Down Expand Up @@ -221,8 +223,8 @@ func Validate(cfg *Config) error {
if cfg.Server.Host == "" {
return fmt.Errorf("server.host is required")
}
if !IsLoopbackURLHost(cfg.Server.Host) {
return fmt.Errorf("server.host %q is not loopback; Beacon supports local dashboards only", cfg.Server.Host)
if !IsLoopbackURLHost(cfg.Server.Host) && !cfg.Server.AllowNonLoopback {
return fmt.Errorf("server.host %q is not loopback; set server.allow_non_loopback = true to bind trusted private interfaces", cfg.Server.Host)
}
if err := validatePort("server.port", cfg.Server.Port); err != nil {
return err
Expand Down
28 changes: 27 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Server.Port != 4600 {
t.Errorf("Server.Port = %d, want 4600", cfg.Server.Port)
}
if cfg.Server.AllowNonLoopback {
t.Error("Server.AllowNonLoopback = true, want false")
}
if cfg.Database.Database != "beacon" || cfg.Database.ReadPoolSize != 8 {
t.Errorf("Database defaults = %#v", cfg.Database)
}
Expand Down Expand Up @@ -193,6 +196,28 @@ host = "127.0.0.2"
}
}

func TestLoadAllowsConfiguredNonLoopbackServerHost(t *testing.T) {
path := filepath.Join(t.TempDir(), "beacon.toml")
if err := os.WriteFile(path, []byte(`
[server]
host = "0.0.0.0"
allow_non_loopback = true
`), 0644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host)
}
if !cfg.Server.AllowNonLoopback {
t.Fatal("Server.AllowNonLoopback = false, want true")
}
}

func TestLoadExplicitMissingConfigFileFails(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "missing.toml"))
if err == nil {
Expand All @@ -207,7 +232,7 @@ func TestLoadInvalidValues(t *testing.T) {
wantErr string
}{
{name: "server port", body: "[server]\nport = 0\n", wantErr: "server.port must be between 1 and 65535"},
{name: "server host non loopback", body: "[server]\nhost = \"0.0.0.0\"\n", wantErr: "server.host"},
{name: "server host non loopback", body: "[server]\nhost = \"0.0.0.0\"\n", wantErr: "server.allow_non_loopback"},
{name: "database address", body: "[database]\naddrs = [\"127.0.0.1\"]\n", wantErr: "database.addrs[0] must be host:port"},
{name: "database name", body: "[database]\ndatabase = \"beacon-prod\"\n", wantErr: "database.database"},
{name: "reconcile duration", body: "[capture]\nreconcile_interval = \"0s\"\n", wantErr: "capture.reconcile_interval must be positive"},
Expand Down Expand Up @@ -302,6 +327,7 @@ func TestValidateInvalidFields(t *testing.T) {
}{
{name: "nil", mutate: nil, wantErr: "config is nil"},
{name: "server host", mutate: func(c *Config) { c.Server.Host = " " }, wantErr: "server.host is required"},
{name: "server host non loopback without opt in", mutate: func(c *Config) { c.Server.Host = "0.0.0.0" }, wantErr: "server.allow_non_loopback"},
{name: "empty addrs", mutate: func(c *Config) { c.Database.Addrs = nil }, wantErr: "database.addrs must contain"},
{name: "empty addr", mutate: func(c *Config) { c.Database.Addrs = []string{" "} }, wantErr: "database.addrs[0] is required"},
{name: "addr host", mutate: func(c *Config) { c.Database.Addrs = []string{":9000"} }, wantErr: "database.addrs[0] host is required"},
Expand Down
Loading