Skip to content

Commit 213121e

Browse files
authored
feat(server): opt-in DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED to bind non-loopback without api key (#98)
* checkpoint: pre-yolo 2026-05-04T06:17:27 * feat(server): allow unauthenticated non-loopback bind via opt-in override Today docsiq refuses to start when server.api_key is empty and the bind host is anything other than loopback. That's the right safe default, but it makes deployments to trusted private networks (homelabs, air-gapped clusters, single-tenant LANs) require an api key just for the boot gate, with no real adversary on the network. Add server.allow_unauthenticated (DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true) that downgrades the non-loopback refusal to a loud warning. Default is false — every existing install behaves identically. Operators who set the override get a prominent boot-time warning naming the bound host and port plus an explicit "do NOT enable on the public internet" line. The boot-time error messages now mention the override env var so anyone hitting the refusal sees both escape hatches (set a key OR set the override) without grepping the docs. Tests: 2 new cmd-level cases (override allows non-loopback bind across '', 0.0.0.0, RFC1918 hosts; error messages mention the override env var) and 1 new config-level case proving DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED is reachable via env (regression guard for viper #761 if BindEnv loop ever regresses).
1 parent 6956e82 commit 213121e

5 files changed

Lines changed: 132 additions & 12 deletions

File tree

.release-notes-v0.1.5.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## [0.1.5] — 2026-05-04
2+
3+
Build-pipeline fix so `go install github.com/RandomCodeSpace/docsiq@v0.1.5`
4+
produces a binary with the embedded React UI included. **No application
5+
source changes vs v0.1.4** — prebuilt signed release binaries are
6+
behaviorally identical.
7+
8+
### Fixed
9+
10+
- **`go install` now ships a working UI.** Previously `ui/dist/` was
11+
gitignored on `main` (only a placeholder `index.html` was committed),
12+
so `proxy.golang.org` mirrored a tree without the hashed JS/CSS
13+
bundles, and `//go:embed ui/dist` baked in only the placeholder. The
14+
binary 404'd on every `/assets/*` request. The release workflow now
15+
creates an ephemeral commit on a detached HEAD that force-adds the
16+
freshly-built `ui/dist/`, and tags *that* commit as `v0.1.5`. `main`
17+
itself is unchanged. ([#97])
18+
19+
### Changed
20+
21+
- `.github/workflows/release.yml``release:` job now downloads the
22+
`ui-dist` artifact, force-adds `ui/dist/` past `.gitignore`, commits
23+
on a detached HEAD, tags the new commit, and pushes only the tag.
24+
25+
### Upgrade impact
26+
27+
**Drop-in.** No code or config changes. If you were already using
28+
prebuilt signed binaries from the release page, behavior is identical
29+
to v0.1.4. If you were using `go install`, the embedded UI now
30+
actually works — you no longer need to download a release asset just
31+
to use `docsiq serve`.
32+
33+
Reminder: docsiq still needs the `sqlite_fts5` build tag (and a C
34+
toolchain) for `go install`, per the v0.1.4 notes:
35+
36+
```sh
37+
GOFLAGS='-tags=sqlite_fts5' go install github.com/RandomCodeSpace/docsiq@v0.1.5
38+
```
39+
40+
[#97]: https://github.com/RandomCodeSpace/docsiq/pull/97

cmd/serve.go

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -243,28 +243,46 @@ func init() {
243243
// empty AND the bind host is not loopback. An unauthenticated service
244244
// exposed on the network is almost never intentional; make it explicit.
245245
// Loopback with empty key gets a prominent warning at boot instead.
246+
//
247+
// The server.allow_unauthenticated config key (DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true)
248+
// downgrades the non-loopback refusal to a loud warning. Intended for
249+
// trusted private networks and air-gapped lab setups where setting an
250+
// API key is impractical; the override is opt-in so default deploys
251+
// can never accidentally expose unauthenticated docsiq to the network.
246252
func validateServeSecurity(cfg *config.Config) error {
247253
if cfg.Server.APIKey != "" {
248254
return nil
249255
}
250256
host := strings.ToLower(strings.TrimSpace(cfg.Server.Host))
251-
if host == "" {
252-
return fmt.Errorf(
253-
"server.api_key is empty and server.host is unset (binds all interfaces); refusing to start. " +
254-
"Set DOCSIQ_SERVER_API_KEY or bind to 127.0.0.1/localhost for dev",
255-
)
256-
}
257257
loopback := host == "localhost"
258258
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
259259
loopback = loopback || ip.IsLoopback()
260260
}
261-
if !loopback {
261+
if loopback {
262+
slog.Warn("⚠️ auth disabled (empty server.api_key); only loopback bind allowed", "host", host)
263+
return nil
264+
}
265+
if cfg.Server.AllowUnauthenticated {
266+
exposure := host
267+
if exposure == "" {
268+
exposure = "all interfaces"
269+
}
270+
slog.Warn(
271+
"⚠️ auth disabled and server.allow_unauthenticated=true — anyone reachable on this network can read all data and use the LLM proxy; do NOT enable on the public internet",
272+
"host", exposure,
273+
"port", cfg.Server.Port,
274+
)
275+
return nil
276+
}
277+
if host == "" {
262278
return fmt.Errorf(
263-
"server.api_key is empty and server.host=%q is not loopback; refusing to start. "+
264-
"Set DOCSIQ_SERVER_API_KEY or bind to 127.0.0.1/localhost for dev",
265-
cfg.Server.Host,
279+
"server.api_key is empty and server.host is unset (binds all interfaces); refusing to start. " +
280+
"Set DOCSIQ_SERVER_API_KEY, bind to 127.0.0.1/localhost for dev, or set DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true to override (trusted networks only)",
266281
)
267282
}
268-
slog.Warn("⚠️ auth disabled (empty server.api_key); only loopback bind allowed", "host", host)
269-
return nil
283+
return fmt.Errorf(
284+
"server.api_key is empty and server.host=%q is not loopback; refusing to start. "+
285+
"Set DOCSIQ_SERVER_API_KEY, bind to 127.0.0.1/localhost for dev, or set DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true to override (trusted networks only)",
286+
cfg.Server.Host,
287+
)
270288
}

cmd/serve_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,37 @@ func TestValidateServeSecurity_AllowsNonLoopbackWithKey(t *testing.T) {
8585
t.Fatalf("expected nil; got %v", err)
8686
}
8787
}
88+
89+
func TestValidateServeSecurity_AllowsNonLoopbackWithOverride(t *testing.T) {
90+
t.Parallel()
91+
hosts := []string{"", "0.0.0.0", "10.0.0.5", "192.168.1.42"}
92+
for _, host := range hosts {
93+
t.Run(host, func(t *testing.T) {
94+
t.Parallel()
95+
cfg := &config.Config{}
96+
cfg.Server.Host = host
97+
cfg.Server.Port = 8080
98+
cfg.Server.APIKey = ""
99+
cfg.Server.AllowUnauthenticated = true
100+
if err := validateServeSecurity(cfg); err != nil {
101+
t.Fatalf("host=%q: expected nil with allow_unauthenticated=true; got %v", host, err)
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestValidateServeSecurity_OverrideHintAppearsInErrors(t *testing.T) {
108+
t.Parallel()
109+
cfg := &config.Config{}
110+
cfg.Server.Host = "0.0.0.0"
111+
cfg.Server.APIKey = ""
112+
cfg.Server.AllowUnauthenticated = false
113+
114+
err := validateServeSecurity(cfg)
115+
if err == nil {
116+
t.Fatal("expected error; got nil")
117+
}
118+
if !strings.Contains(err.Error(), "DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED") {
119+
t.Errorf("error should mention the override env var; got %v", err)
120+
}
121+
}

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ type ServerConfig struct {
181181
// (POST /api/upload, POST /api/projects/{project}/import). Block
182182
// 3.2 default 10m.
183183
UploadTimeout time.Duration `mapstructure:"upload_timeout"`
184+
185+
// AllowUnauthenticated lets docsiq start with an empty server.api_key
186+
// even when bound to a non-loopback host. Off by default — flipping it
187+
// on exposes every indexed document and the LLM proxy to anyone who
188+
// can reach the bind address. Intended for trusted private networks
189+
// and air-gapped lab setups; never enable on the public internet.
190+
AllowUnauthenticated bool `mapstructure:"allow_unauthenticated"`
184191
}
185192

186193
func Load(cfgFile string) (*Config, error) {
@@ -253,6 +260,7 @@ func Load(cfgFile string) (*Config, error) {
253260
v.SetDefault("server.hsts_enabled", false)
254261
v.SetDefault("server.request_timeout", 30*time.Second)
255262
v.SetDefault("server.upload_timeout", 10*time.Minute)
263+
v.SetDefault("server.allow_unauthenticated", false)
256264

257265
// Log format: "text" for human dev output, "json" for production.
258266
v.SetDefault("log.format", "text")

internal/config/config_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,23 @@ func TestLoad_EnvOverridesLLM(t *testing.T) {
761761
t.Errorf("DataDir = %q, want %q", got, want)
762762
}
763763
}
764+
765+
// TestLoad_EnvOverridesServerAllowUnauthenticated guards the security-
766+
// sensitive flag that downgrades the non-loopback boot refusal to a
767+
// warning. If env binding ever regresses for this key, deployments
768+
// expecting DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true would silently
769+
// hit "refusing to start" — the regression must be loud.
770+
func TestLoad_EnvOverridesServerAllowUnauthenticated(t *testing.T) {
771+
home := t.TempDir()
772+
isolateEnv(t, home)
773+
774+
t.Setenv("DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED", "true")
775+
776+
cfg, err := Load("")
777+
if err != nil {
778+
t.Fatalf("Load: %v", err)
779+
}
780+
if !cfg.Server.AllowUnauthenticated {
781+
t.Errorf("Server.AllowUnauthenticated = false, want true")
782+
}
783+
}

0 commit comments

Comments
 (0)