diff --git a/.release-notes-v0.1.5.md b/.release-notes-v0.1.5.md new file mode 100644 index 0000000..283247c --- /dev/null +++ b/.release-notes-v0.1.5.md @@ -0,0 +1,40 @@ +## [0.1.5] — 2026-05-04 + +Build-pipeline fix so `go install github.com/RandomCodeSpace/docsiq@v0.1.5` +produces a binary with the embedded React UI included. **No application +source changes vs v0.1.4** — prebuilt signed release binaries are +behaviorally identical. + +### Fixed + +- **`go install` now ships a working UI.** Previously `ui/dist/` was + gitignored on `main` (only a placeholder `index.html` was committed), + so `proxy.golang.org` mirrored a tree without the hashed JS/CSS + bundles, and `//go:embed ui/dist` baked in only the placeholder. The + binary 404'd on every `/assets/*` request. The release workflow now + creates an ephemeral commit on a detached HEAD that force-adds the + freshly-built `ui/dist/`, and tags *that* commit as `v0.1.5`. `main` + itself is unchanged. ([#97]) + +### Changed + +- `.github/workflows/release.yml` — `release:` job now downloads the + `ui-dist` artifact, force-adds `ui/dist/` past `.gitignore`, commits + on a detached HEAD, tags the new commit, and pushes only the tag. + +### Upgrade impact + +**Drop-in.** No code or config changes. If you were already using +prebuilt signed binaries from the release page, behavior is identical +to v0.1.4. If you were using `go install`, the embedded UI now +actually works — you no longer need to download a release asset just +to use `docsiq serve`. + +Reminder: docsiq still needs the `sqlite_fts5` build tag (and a C +toolchain) for `go install`, per the v0.1.4 notes: + +```sh +GOFLAGS='-tags=sqlite_fts5' go install github.com/RandomCodeSpace/docsiq@v0.1.5 +``` + +[#97]: https://github.com/RandomCodeSpace/docsiq/pull/97 diff --git a/cmd/serve.go b/cmd/serve.go index d936fb6..ca85ae2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -243,28 +243,46 @@ func init() { // empty AND the bind host is not loopback. An unauthenticated service // exposed on the network is almost never intentional; make it explicit. // Loopback with empty key gets a prominent warning at boot instead. +// +// The server.allow_unauthenticated config key (DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true) +// downgrades the non-loopback refusal to a loud warning. Intended for +// trusted private networks and air-gapped lab setups where setting an +// API key is impractical; the override is opt-in so default deploys +// can never accidentally expose unauthenticated docsiq to the network. func validateServeSecurity(cfg *config.Config) error { if cfg.Server.APIKey != "" { return nil } host := strings.ToLower(strings.TrimSpace(cfg.Server.Host)) - if host == "" { - return fmt.Errorf( - "server.api_key is empty and server.host is unset (binds all interfaces); refusing to start. " + - "Set DOCSIQ_SERVER_API_KEY or bind to 127.0.0.1/localhost for dev", - ) - } loopback := host == "localhost" if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { loopback = loopback || ip.IsLoopback() } - if !loopback { + if loopback { + slog.Warn("⚠️ auth disabled (empty server.api_key); only loopback bind allowed", "host", host) + return nil + } + if cfg.Server.AllowUnauthenticated { + exposure := host + if exposure == "" { + exposure = "all interfaces" + } + slog.Warn( + "⚠️ 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", + "host", exposure, + "port", cfg.Server.Port, + ) + return nil + } + if host == "" { return fmt.Errorf( - "server.api_key is empty and server.host=%q is not loopback; refusing to start. "+ - "Set DOCSIQ_SERVER_API_KEY or bind to 127.0.0.1/localhost for dev", - cfg.Server.Host, + "server.api_key is empty and server.host is unset (binds all interfaces); refusing to start. " + + "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)", ) } - slog.Warn("⚠️ auth disabled (empty server.api_key); only loopback bind allowed", "host", host) - return nil + return fmt.Errorf( + "server.api_key is empty and server.host=%q is not loopback; refusing to start. "+ + "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)", + cfg.Server.Host, + ) } diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 7480f90..a685e31 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -85,3 +85,37 @@ func TestValidateServeSecurity_AllowsNonLoopbackWithKey(t *testing.T) { t.Fatalf("expected nil; got %v", err) } } + +func TestValidateServeSecurity_AllowsNonLoopbackWithOverride(t *testing.T) { + t.Parallel() + hosts := []string{"", "0.0.0.0", "10.0.0.5", "192.168.1.42"} + for _, host := range hosts { + t.Run(host, func(t *testing.T) { + t.Parallel() + cfg := &config.Config{} + cfg.Server.Host = host + cfg.Server.Port = 8080 + cfg.Server.APIKey = "" + cfg.Server.AllowUnauthenticated = true + if err := validateServeSecurity(cfg); err != nil { + t.Fatalf("host=%q: expected nil with allow_unauthenticated=true; got %v", host, err) + } + }) + } +} + +func TestValidateServeSecurity_OverrideHintAppearsInErrors(t *testing.T) { + t.Parallel() + cfg := &config.Config{} + cfg.Server.Host = "0.0.0.0" + cfg.Server.APIKey = "" + cfg.Server.AllowUnauthenticated = false + + err := validateServeSecurity(cfg) + if err == nil { + t.Fatal("expected error; got nil") + } + if !strings.Contains(err.Error(), "DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED") { + t.Errorf("error should mention the override env var; got %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e917b91..e890045 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -181,6 +181,13 @@ type ServerConfig struct { // (POST /api/upload, POST /api/projects/{project}/import). Block // 3.2 default 10m. UploadTimeout time.Duration `mapstructure:"upload_timeout"` + + // AllowUnauthenticated lets docsiq start with an empty server.api_key + // even when bound to a non-loopback host. Off by default — flipping it + // on exposes every indexed document and the LLM proxy to anyone who + // can reach the bind address. Intended for trusted private networks + // and air-gapped lab setups; never enable on the public internet. + AllowUnauthenticated bool `mapstructure:"allow_unauthenticated"` } func Load(cfgFile string) (*Config, error) { @@ -253,6 +260,7 @@ func Load(cfgFile string) (*Config, error) { v.SetDefault("server.hsts_enabled", false) v.SetDefault("server.request_timeout", 30*time.Second) v.SetDefault("server.upload_timeout", 10*time.Minute) + v.SetDefault("server.allow_unauthenticated", false) // Log format: "text" for human dev output, "json" for production. v.SetDefault("log.format", "text") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ab0744..01f7a93 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -761,3 +761,23 @@ func TestLoad_EnvOverridesLLM(t *testing.T) { t.Errorf("DataDir = %q, want %q", got, want) } } + +// TestLoad_EnvOverridesServerAllowUnauthenticated guards the security- +// sensitive flag that downgrades the non-loopback boot refusal to a +// warning. If env binding ever regresses for this key, deployments +// expecting DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED=true would silently +// hit "refusing to start" — the regression must be loud. +func TestLoad_EnvOverridesServerAllowUnauthenticated(t *testing.T) { + home := t.TempDir() + isolateEnv(t, home) + + t.Setenv("DOCSIQ_SERVER_ALLOW_UNAUTHENTICATED", "true") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Server.AllowUnauthenticated { + t.Errorf("Server.AllowUnauthenticated = false, want true") + } +}