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
40 changes: 40 additions & 0 deletions .release-notes-v0.1.5.md
Original file line number Diff line number Diff line change
@@ -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
42 changes: 30 additions & 12 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
34 changes: 34 additions & 0 deletions cmd/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading