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
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,26 @@ are written to `~/.antares/logs/daemon.log`.

### Accessing it from another machine

The production binary binds `127.0.0.1` by default. Change `server.host` to
`0.0.0.0` (or a specific interface) to reach it from elsewhere; a non-loopback
bind requires `server.auth_token`, a dashboard password, or an explicit
`server.auth_disabled: true`. Vite also binds loopback in dev; set `HOST=0.0.0.0`
to expose it on the network.
The production binary binds `127.0.0.1` by default. Inside a container the
first boot seeds `server.host: 0.0.0.0` into `config.yaml` once so `docker
run -p 8787:8787` and Kubernetes port-forwards reach it out of the box;
later boots read the stored value verbatim so your edits stick. To expose
the binary elsewhere, edit `server.host` in `config.yaml` or export
`ANTARES_HOST` before starting. `ANTARES_HOST` is a per-process override
applied on every load — it wins for the current run but is **not** written
to disk, so unsetting it restores the stored value on the next boot. Vite
also binds loopback in dev; set `HOST=0.0.0.0` to expose it on the LAN.

```
http://<tailscale-ip>:8787 # production binary, after setting server.host
http://<tailscale-ip>:8787 # production binary, after editing server.host
ANTARES_HOST=0.0.0.0 antares # one-off exposure via env var; config.yaml is not changed
HOST=0.0.0.0 make dev-web # dev, exposed on the LAN
```

Antares refuses to bind a non-loopback address unless `server.auth_token` is
set, a dashboard password is configured, or `server.auth_disabled: true` is
set explicitly — the default `127.0.0.1` binding leaves the dashboard open,
which is right on your own machine and safe behind a private network.
A non-loopback bind still requires `server.auth_token`, a dashboard password,
or an explicit `server.auth_disabled: true` — Antares refuses to start
otherwise. The loopback default leaves the dashboard open, which is right on
your own machine and safe behind a private network.

---

Expand Down
52 changes: 48 additions & 4 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package config

import "path/filepath"
import (
"bytes"
"os"
"path/filepath"
)

// Default returns a fully populated configuration. Every field the dashboard
// can edit has a sensible value here so the UI never renders a blank form.
Expand Down Expand Up @@ -50,9 +54,11 @@ func Default() *Config {
MaxConns: 8, Busy: 5000, WAL: true,
},
Server: Server{
Host: "0.0.0.0", Port: 8787,
// Same-origin is the safe default. Cross-origin access must be
// explicitly configured by the operator.
Host: defaultHost(), Port: 8787,
// defaultHost() seeds loopback on bare metal, wildcard in a
// container. ANTARES_HOST is a per-load runtime override
// applied in applyEnv; it never touches the persisted seed.
// CORS starts same-origin only.
CORSOrigins: []string{},
},
Agent: Agent{
Expand Down Expand Up @@ -143,3 +149,41 @@ func Default() *Config {
},
}
}

// defaultHost picks the seed for a fresh config.yaml. It does not read
// ANTARES_HOST — that env var is a runtime override applied by applyEnv, so
// a transient export never gets baked into the persisted file.
func defaultHost() string {
if inContainer() {
return "0.0.0.0"
}
return "127.0.0.1"
}

// containerProbe is the seam tests replace to simulate running inside a
// container without needing Docker. Production code calls the real probe.
var containerProbe = detectContainer

// inContainer reports whether Antares is running inside a container. It goes
// through the swappable containerProbe so tests can stub the answer.
func inContainer() bool { return containerProbe() }

// detectContainer looks at filesystem markers Docker, Podman, and
// Kubernetes leave in the container image. Any failure means "not detected"
// — the loopback fallback is the safe answer if we cannot tell.
func detectContainer() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return true
}
if b, err := os.ReadFile("/proc/1/cgroup"); err == nil {
if bytes.Contains(b, []byte("docker")) ||
bytes.Contains(b, []byte("kubepods")) ||
bytes.Contains(b, []byte("containerd")) {
return true
}
}
return false
}
46 changes: 46 additions & 0 deletions internal/config/defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package config

import "testing"

// TestDefaultHost pins the seed: loopback on bare metal, wildcard in a
// container. ANTARES_HOST must NOT influence the seed — that is applyEnv's
// job, and mixing the two is what let a transient env value persist into
// config.yaml.
func TestDefaultHost(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
t.Setenv("ANTARES_HOST", "203.0.113.9") // must be ignored

tests := []struct {
name string
container bool
want string
}{
{name: "bare metal loopback", container: false, want: "127.0.0.1"},
{name: "container wildcard", container: true, want: "0.0.0.0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
containerProbe = func() bool { return tc.container }
if got := defaultHost(); got != tc.want {
t.Fatalf("defaultHost() = %q, want %q — env leaked into the seed", got, tc.want)
}
})
}
}

// TestDefaultServerHostIgnoresEnv is the direct regression: Default() must
// not consult ANTARES_HOST, otherwise the value gets written to config.yaml
// on first boot and every later boot binds that address even after the env
// export is gone.
func TestDefaultServerHostIgnoresEnv(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
containerProbe = func() bool { return false }
t.Setenv("ANTARES_HOST", "10.9.8.7")

cfg := Default()
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("Default().Server.Host = %q, want %q — ANTARES_HOST leaked into the seed", cfg.Server.Host, "127.0.0.1")
}
}
213 changes: 213 additions & 0 deletions internal/config/load_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package config

import (
"os"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

// resetLoadedForTest wipes the package cache so each Reload goes through the
// disk seed path again.
func resetLoadedForTest(t *testing.T) {
t.Helper()
mu.Lock()
loaded = nil
mu.Unlock()
}

// isolateConfigHome points ANTARES_HOME at a fresh temp dir and clears the
// cache. Returns the home path.
func isolateConfigHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("ANTARES_HOME", dir)
t.Setenv("ANTARES_PROFILE", "default")
t.Setenv("ANTARES_CONFIG", "")
resetLoadedForTest(t)
return dir
}

// TestReloadDoesNotPersistTransientHostEnv is the regression for the
// ANTARES_HOST-persisting bug. First boot with env=0.0.0.0 on bare metal:
// runtime wins the env value, on-disk seed stays loopback. Second boot with
// the env cleared: runtime falls back to the persisted loopback, not the
// previous env value.
func TestReloadDoesNotPersistTransientHostEnv(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
containerProbe = func() bool { return false }

home := isolateConfigHome(t)
cfgPath := filepath.Join(home, "config.yaml")

// Boot 1: env=0.0.0.0. Env wins in memory; disk is the loopback seed.
t.Setenv("ANTARES_HOST", "0.0.0.0")
cfg, err := Reload()
if err != nil {
t.Fatalf("first Reload: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("boot 1 runtime host = %q, want %q", cfg.Server.Host, "0.0.0.0")
}
if got := readHostFromDisk(t, cfgPath); got != "127.0.0.1" {
t.Fatalf("boot 1 persisted host = %q, want %q — ANTARES_HOST leaked into config.yaml", got, "127.0.0.1")
}

// Boot 2: env cleared. Runtime falls back to the persisted loopback.
os.Unsetenv("ANTARES_HOST")
resetLoadedForTest(t)
cfg, err = Reload()
if err != nil {
t.Fatalf("second Reload: %v", err)
}
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("boot 2 runtime host = %q, want %q — env value persisted across boots", cfg.Server.Host, "127.0.0.1")
}
if got := readHostFromDisk(t, cfgPath); got != "127.0.0.1" {
t.Fatalf("boot 2 persisted host = %q, want %q", got, "127.0.0.1")
}
}

// TestReloadPreservesYAMLHost proves an operator-authored server.host wins
// across boots and is not overwritten by defaultHost() or by an unset env.
func TestReloadPreservesYAMLHost(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
containerProbe = func() bool { return false }

home := isolateConfigHome(t)
cfgPath := filepath.Join(home, "config.yaml")
writeYAMLHost(t, cfgPath, "192.168.42.10")

os.Unsetenv("ANTARES_HOST")
cfg, err := Reload()
if err != nil {
t.Fatalf("Reload: %v", err)
}
if cfg.Server.Host != "192.168.42.10" {
t.Fatalf("runtime host = %q, want the pinned YAML value", cfg.Server.Host)
}
if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" {
t.Fatalf("persisted host = %q, want %q — operator edit was overwritten", got, "192.168.42.10")
}
}

// TestReloadEnvOverridesYAMLHost pins the documented precedence: ANTARES_HOST
// beats the stored server.host for the current process, without rewriting it.
func TestReloadEnvOverridesYAMLHost(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
containerProbe = func() bool { return false }

home := isolateConfigHome(t)
cfgPath := filepath.Join(home, "config.yaml")
writeYAMLHost(t, cfgPath, "192.168.42.10")

t.Setenv("ANTARES_HOST", "10.0.0.1")
cfg, err := Reload()
if err != nil {
t.Fatalf("Reload: %v", err)
}
if cfg.Server.Host != "10.0.0.1" {
t.Fatalf("runtime host = %q, want the env override", cfg.Server.Host)
}
if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" {
t.Fatalf("persisted host = %q, want %q — env override rewrote the YAML", got, "192.168.42.10")
}
}

// TestReloadWhitespaceHostEnvIgnored: applyEnv treats whitespace-only exports
// as unset. A stray `export ANTARES_HOST=" "` must not blank the host.
func TestReloadWhitespaceHostEnvIgnored(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })
containerProbe = func() bool { return false }

home := isolateConfigHome(t)
cfgPath := filepath.Join(home, "config.yaml")
writeYAMLHost(t, cfgPath, "192.168.42.10")

t.Setenv("ANTARES_HOST", " ")
cfg, err := Reload()
if err != nil {
t.Fatalf("Reload: %v", err)
}
if cfg.Server.Host != "192.168.42.10" {
t.Fatalf("runtime host = %q, want the pinned YAML value — whitespace env bypassed the trim check", cfg.Server.Host)
}
if got := readHostFromDisk(t, cfgPath); got != "192.168.42.10" {
t.Fatalf("persisted host = %q, want %q", got, "192.168.42.10")
}
}

// TestReloadContainerSeedPersists: the container heuristic runs once, its
// wildcard is written to disk, and a later boot where the heuristic no
// longer trips still reads that wildcard verbatim. Reload never re-probes.
func TestReloadContainerSeedPersists(t *testing.T) {
origProbe := containerProbe
t.Cleanup(func() { containerProbe = origProbe })

home := isolateConfigHome(t)
cfgPath := filepath.Join(home, "config.yaml")

containerProbe = func() bool { return true }
os.Unsetenv("ANTARES_HOST")
cfg, err := Reload()
if err != nil {
t.Fatalf("first Reload: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("boot 1 runtime host = %q, want %q", cfg.Server.Host, "0.0.0.0")
}
if got := readHostFromDisk(t, cfgPath); got != "0.0.0.0" {
t.Fatalf("boot 1 persisted host = %q, want %q", got, "0.0.0.0")
}

containerProbe = func() bool { return false }
resetLoadedForTest(t)
cfg, err = Reload()
if err != nil {
t.Fatalf("second Reload: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Fatalf("boot 2 runtime host = %q, want %q — persisted seed lost", cfg.Server.Host, "0.0.0.0")
}
if got := readHostFromDisk(t, cfgPath); got != "0.0.0.0" {
t.Fatalf("boot 2 persisted host = %q, want %q — seed rewritten on re-probe", got, "0.0.0.0")
}
}

// readHostFromDisk decodes config.yaml directly so a bug that only shows up
// in the persisted value cannot hide behind applyEnv.
func readHostFromDisk(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc struct {
Server struct {
Host string `yaml:"host"`
} `yaml:"server"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return strings.TrimSpace(doc.Server.Host)
}

// writeYAMLHost stages a minimal config.yaml with a pinned server.host,
// mirroring an operator's direct edit.
func writeYAMLHost(t *testing.T, path string, host string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "server:\n host: " + host + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
Loading