Skip to content

Commit 9d30467

Browse files
Merge pull request #19 from kvokka/configurable-host-path
2 parents b42901a + 0d543d5 commit 9d30467

File tree

7 files changed

+190
-15
lines changed

7 files changed

+190
-15
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ oh-my-opencode-dashboard
8383
Options:
8484

8585
- `--project <path>` (optional): project root used for plan lookup + session filtering (defaults to current working directory)
86+
- `--host <hostname>` (optional): server bind host (defaults to `127.0.0.1`; can also be set with `OMO_DASHBOARD_HOST`)
8687
- `--port <number>` (optional): default 51234
8788

8889
## Install (from source)
@@ -106,6 +107,26 @@ bun run build
106107
bun run start -- --project /absolute/path/to/your/project
107108
```
108109

110+
Bind to localhost explicitly:
111+
112+
```bash
113+
OMO_DASHBOARD_HOST=localhost bun run start -- --project /absolute/path/to/your/project
114+
```
115+
116+
Expose the server outside localhost (for example from a container):
117+
118+
```bash
119+
OMO_DASHBOARD_HOST=0.0.0.0 bun run start -- --project /absolute/path/to/your/project
120+
```
121+
122+
Or via CLI:
123+
124+
```bash
125+
bun run start -- --project /absolute/path/to/your/project --host 0.0.0.0
126+
```
127+
128+
Use `0.0.0.0` only on trusted networks. It binds the server on all interfaces, while `localhost` keeps it local-only.
129+
109130
## What It Reads (SQLite + Legacy)
110131

111132
- Project (optional; OhMyOpenCode plan tracking):
@@ -146,7 +167,7 @@ This dashboard is designed to avoid sensitive data:
146167

147168
## Security
148169

149-
- Server binds to `127.0.0.1` only.
170+
- Server binds to `127.0.0.1` by default.
150171
- Path access is allowlisted and realpath-based to prevent symlink escape:
151172
- project root
152173
- OpenCode storage root

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "oh-my-opencode-dashboard",
3-
"version": "0.4.1",
3+
"version": "0.4.2",
44
"description": "Local-only, read-only dashboard for viewing OhMyOpenCode agent progress",
55
"license": "SUL-1.0",
66
"repository": {

src/server/host.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { DEFAULT_HOST, getPublicHost, resolveServerHost } from "./host"
4+
5+
describe("resolveServerHost", () => {
6+
it("uses the default host when no override is provided", () => {
7+
expect(resolveServerHost({})).toBe(DEFAULT_HOST)
8+
})
9+
10+
it("uses the environment host when provided", () => {
11+
expect(resolveServerHost({ envHost: "localhost" })).toBe("localhost")
12+
})
13+
14+
it("prefers the CLI host over the environment host", () => {
15+
expect(resolveServerHost({ cliHost: "0.0.0.0", envHost: "localhost" })).toBe("0.0.0.0")
16+
})
17+
18+
it("ignores empty host values", () => {
19+
expect(resolveServerHost({ cliHost: " ", envHost: "" })).toBe(DEFAULT_HOST)
20+
})
21+
})
22+
23+
describe("getPublicHost", () => {
24+
it("returns the host unchanged when it is already user-facing", () => {
25+
expect(getPublicHost("localhost")).toBe("localhost")
26+
})
27+
28+
it("maps 0.0.0.0 to localhost for startup logs", () => {
29+
expect(getPublicHost("0.0.0.0")).toBe("localhost")
30+
})
31+
32+
it("maps :: to localhost for startup logs", () => {
33+
expect(getPublicHost("::")).toBe("localhost")
34+
})
35+
})

src/server/host.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const DEFAULT_HOST = "127.0.0.1"
2+
3+
function normalizeHost(value: string | undefined): string | null {
4+
if (typeof value !== "string") return null
5+
const trimmed = value.trim()
6+
return trimmed.length > 0 ? trimmed : null
7+
}
8+
9+
export function resolveServerHost(opts: { cliHost?: string; envHost?: string }): string {
10+
return normalizeHost(opts.cliHost) ?? normalizeHost(opts.envHost) ?? DEFAULT_HOST
11+
}
12+
13+
export function getPublicHost(host: string): string {
14+
return host === "0.0.0.0" || host === "::" ? "localhost" : host
15+
}
16+
17+
export { DEFAULT_HOST }

src/server/start.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
#!/usr/bin/env bun
2-
import { Hono } from 'hono'
32
import * as fs from "node:fs"
43
import { basename, join } from 'node:path'
5-
import { parseArgs } from 'util'
4+
import { parseArgs } from 'node:util'
5+
import { Hono } from 'hono'
6+
import { addOrUpdateSource, listSources } from "../ingest/sources-registry"
7+
import { getLegacyStorageRootForBackend, selectStorageBackend } from "../ingest/storage-backend"
68
import { createApi } from "./api"
79
import { createDashboardStore, type DashboardStore } from "./dashboard"
8-
import { getLegacyStorageRootForBackend, selectStorageBackend } from "../ingest/storage-backend"
9-
import { addOrUpdateSource, listSources } from "../ingest/sources-registry"
10+
import { getPublicHost, resolveServerHost } from "./host"
11+
import { resolveStaticFilePath } from "./static-file"
1012

1113
function isBunxInvocation(argv: string[]): boolean {
1214
if (process.env.BUN_INSTALL_CACHE_DIR) return true
@@ -20,14 +22,16 @@ const { values, positionals } = parseArgs({
2022
options: {
2123
project: { type: 'string' },
2224
port: { type: 'string' },
25+
host: { type: 'string' },
2326
name: { type: 'string' },
2427
},
2528
allowPositionals: true,
2629
})
2730

2831
const project = values.project ?? process.cwd()
2932

30-
const port = parseInt(values.port || '51234')
33+
const port = parseInt(values.port || '51234', 10)
34+
const host = resolveServerHost({ cliHost: values.host, envHost: process.env.OMO_DASHBOARD_HOST })
3135

3236
const cleanedPositionals = [...positionals]
3337
if (cleanedPositionals[0] === Bun.argv[0]) cleanedPositionals.shift()
@@ -111,12 +115,12 @@ const distRoot = join(import.meta.dir, '../../dist')
111115
// SPA fallback middleware
112116
app.use('*', async (c, next) => {
113117
const path = c.req.path
114-
118+
115119
// Skip API routes - let them pass through
116120
if (path.startsWith('/api/')) {
117121
return await next()
118122
}
119-
123+
120124
// For non-API routes without extensions, serve index.html
121125
if (!path.includes('.')) {
122126
const indexFile = Bun.file(join(distRoot, 'index.html'))
@@ -125,18 +129,22 @@ app.use('*', async (c, next) => {
125129
}
126130
return c.notFound()
127131
}
128-
132+
129133
// For static files with extensions, try to serve them
130-
const relativePath = path.startsWith('/') ? path.slice(1) : path
131-
const file = Bun.file(join(distRoot, relativePath))
134+
const filePath = resolveStaticFilePath(distRoot, path)
135+
if (!filePath) {
136+
return c.notFound()
137+
}
138+
139+
const file = Bun.file(filePath)
132140
if (await file.exists()) {
133141
const ext = path.split('.').pop() || ''
134142
const contentType = getContentType(ext)
135143
return new Response(file, {
136144
headers: { 'Content-Type': contentType }
137145
})
138146
}
139-
147+
140148
return c.notFound()
141149
})
142150

@@ -162,8 +170,13 @@ function getContentType(ext: string): string {
162170

163171
Bun.serve({
164172
fetch: app.fetch,
165-
hostname: '127.0.0.1',
173+
hostname: host,
166174
port,
167175
})
168176

169-
console.log(`Server running on http://127.0.0.1:${port}`)
177+
const publicHost = getPublicHost(host)
178+
if (publicHost === host) {
179+
console.log(`Server running on http://${publicHost}:${port}`)
180+
} else {
181+
console.log(`Server running on http://${publicHost}:${port} (bound to ${host})`)
182+
}

src/server/static-file.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as fs from "node:fs"
2+
import * as os from "node:os"
3+
import * as path from "node:path"
4+
import { describe, expect, it } from "vitest"
5+
6+
import { resolveStaticFilePath } from "./static-file"
7+
8+
function mkTempDir(prefix: string): string {
9+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
10+
}
11+
12+
describe("resolveStaticFilePath", () => {
13+
it("returns a resolved path for files under dist", () => {
14+
const distRoot = path.join("/tmp", "omo-dashboard-dist")
15+
expect(resolveStaticFilePath(distRoot, "/assets/app.js")).toBe(path.join(distRoot, "assets", "app.js"))
16+
})
17+
18+
it("rejects parent-directory traversal", () => {
19+
const distRoot = path.join("/tmp", "omo-dashboard-dist")
20+
expect(resolveStaticFilePath(distRoot, "/../package.json")).toBeNull()
21+
})
22+
23+
it("rejects encoded parent-directory traversal", () => {
24+
const distRoot = path.join("/tmp", "omo-dashboard-dist")
25+
expect(resolveStaticFilePath(distRoot, "/..%2F..%2Fpackage.json")).toBeNull()
26+
})
27+
28+
it("rejects encoded backslash traversal", () => {
29+
const distRoot = path.join("/tmp", "omo-dashboard-dist")
30+
expect(resolveStaticFilePath(distRoot, "/..%5Csecret.txt")).toBeNull()
31+
})
32+
33+
it("rejects invalid URL encoding", () => {
34+
const distRoot = path.join("/tmp", "omo-dashboard-dist")
35+
expect(resolveStaticFilePath(distRoot, "/bad%E0%A4%A.txt")).toBeNull()
36+
})
37+
38+
it("rejects symlink escapes from dist", () => {
39+
const root = mkTempDir("omo-static-file-")
40+
const distRoot = path.join(root, "dist")
41+
const outsideRoot = path.join(root, "outside")
42+
fs.mkdirSync(distRoot, { recursive: true })
43+
fs.mkdirSync(outsideRoot, { recursive: true })
44+
45+
const outsideFile = path.join(outsideRoot, "secret.txt")
46+
fs.writeFileSync(outsideFile, "shh", "utf8")
47+
fs.symlinkSync(outsideFile, path.join(distRoot, "secret.txt"))
48+
49+
expect(resolveStaticFilePath(distRoot, "/secret.txt")).toBeNull()
50+
})
51+
})

src/server/static-file.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { assertAllowedPath } from "../ingest/paths"
2+
3+
function decodeRequestPath(requestPath: string): string | null {
4+
try {
5+
return decodeURIComponent(requestPath)
6+
} catch {
7+
return null
8+
}
9+
}
10+
11+
export function resolveStaticFilePath(distRoot: string, requestPath: string): string | null {
12+
const decodedPath = decodeRequestPath(requestPath)
13+
if (!decodedPath) return null
14+
15+
const relativePath = decodedPath.startsWith("/") ? decodedPath.slice(1) : decodedPath
16+
const normalizedSegments = relativePath
17+
.replace(/\\/g, "/")
18+
.split("/")
19+
.filter((segment) => segment.length > 0 && segment !== ".")
20+
21+
if (normalizedSegments.length === 0 || normalizedSegments.some((segment) => segment === "..")) {
22+
return null
23+
}
24+
25+
try {
26+
return assertAllowedPath({
27+
candidatePath: normalizedSegments.join("/"),
28+
allowedRoots: [distRoot],
29+
baseDir: distRoot,
30+
})
31+
} catch (error) {
32+
if (error instanceof Error && error.message === "Access denied") {
33+
return null
34+
}
35+
36+
throw error
37+
}
38+
}

0 commit comments

Comments
 (0)