Skip to content
Open
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
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/CLIEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ enum CodexBarCLI {
signature: costSignature),
CommandDescriptor(
name: "serve",
abstract: "Serve usage and cost JSON over localhost HTTP",
abstract: "Serve usage, cost, and dashboard JSON over HTTP",
discussion: nil,
signature: serveSignature),
CommandDescriptor(
Expand Down
17 changes: 13 additions & 4 deletions Sources/CodexBarCLI/CLIHelp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,17 @@ extension CodexBarCLI {
CodexBar \(version)

Usage:
codexbar serve [--port <port>] [--refresh-interval <seconds>]
codexbar serve [--host <host>] [--port <port>] [--refresh-interval <seconds>]
[--dashboard-token <token>] [--dashboard-identity none|redacted|full]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>]
[-v|--verbose]

Description:
Start a foreground localhost-only HTTP server that exposes existing CLI JSON payloads.
The server binds to 127.0.0.1 only in this initial version.
Start a foreground HTTP server that exposes CLI JSON payloads and a stable dashboard snapshot.
By default the server binds to 127.0.0.1 only. Non-loopback --host values require
--dashboard-token, and data routes require Authorization: Bearer YOUR_TOKEN when configured.
Dashboard identity exposure defaults to redacted.
Redacted identity keeps plan labels and email domains, but hides email local parts.

Endpoints:
GET /health
Expand All @@ -92,11 +96,15 @@ extension CodexBarCLI {
GET /usage?provider=all
GET /cost
GET /cost?provider=codex
GET /dashboard/v1/snapshot

Examples:
codexbar serve
codexbar serve --port 8080 --refresh-interval 60
codexbar serve --host 0.0.0.0 --port 8080 --dashboard-token "$CODEXBAR_DASHBOARD_TOKEN"
curl http://127.0.0.1:8080/usage?provider=all
curl -H "Authorization: Bearer $CODEXBAR_DASHBOARD_TOKEN" \\
http://127.0.0.1:8080/dashboard/v1/snapshot
"""
}

Expand Down Expand Up @@ -186,7 +194,8 @@ extension CodexBarCLI {
[--json-only]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
[--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh]
codexbar serve [--port <port>] [--refresh-interval <seconds>]
codexbar serve [--host <host>] [--port <port>] [--refresh-interval <seconds>]
[--dashboard-token <token>] [--dashboard-identity none|redacted|full]
[--json-output] [--log-level <trace|verbose|debug|info|warning|error|critical>] [-v|--verbose]
codexbar config <validate|dump|providers> [--format text|json]
[--json]
Expand Down
62 changes: 47 additions & 15 deletions Sources/CodexBarCLI/CLILocalHTTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import Glibc

private let requestReadTimeoutMilliseconds: Int32 = 5000

struct CLILocalHTTPRequest {
struct CLILocalHTTPRequest: Sendable {
let method: String
let target: String
let host: String
let path: String
let queryItems: [String: String]
let headers: [String: String]

static func parse(_ data: Data) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
static func parse(
_ data: Data,
allowNonLoopbackHost: Bool = false) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError>
{
guard let raw = String(data: data, encoding: .utf8),
let firstLine = raw.components(separatedBy: "\r\n").first
else {
Expand All @@ -29,15 +33,19 @@ struct CLILocalHTTPRequest {
guard target.hasPrefix("/") else { return .failure(.invalidRequest) }

let headerResult = Self.parseHeaders(raw)
let headerPairs: [(String, String)]
let host: String
switch headerResult {
case let .success(headers):
let hosts = headers.compactMap { name, value in
case let .success(parsedHeaders):
headerPairs = parsedHeaders
let hosts = parsedHeaders.compactMap { name, value in
name.lowercased() == "host" ? value : nil
}
guard let candidate = hosts.first else { return .failure(.missingHost) }
guard hosts.count == 1 else { return .failure(.duplicateHost) }
guard Self.isAllowedLoopbackHost(candidate) else { return .failure(.disallowedHost) }
guard Self.isAllowedHost(candidate, allowNonLoopbackHost: allowNonLoopbackHost) else {
return .failure(.disallowedHost)
}
host = candidate
case let .failure(error):
return .failure(error)
Expand All @@ -52,12 +60,18 @@ struct CLILocalHTTPRequest {
}
}

var headers: [String: String] = [:]
for (name, value) in headerPairs {
headers[name.lowercased()] = value
}

return .success(CLILocalHTTPRequest(
method: method,
target: target,
host: host,
path: path,
queryItems: queryItems))
queryItems: queryItems,
headers: headers))
}

private static func parseHeaders(_ raw: String) -> Result<[(String, String)], CLILocalHTTPRequestParseError> {
Expand All @@ -78,7 +92,7 @@ struct CLILocalHTTPRequest {
return .success(headers)
}

private static func isAllowedLoopbackHost(_ host: String) -> Bool {
private static func isAllowedHost(_ host: String, allowNonLoopbackHost: Bool) -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.contains(",") else { return false }

Expand All @@ -105,7 +119,7 @@ struct CLILocalHTTPRequest {
case "127.0.0.1", "localhost", "localhost.", "[::1]":
return true
default:
return false
return allowNonLoopbackHost
}
}

Expand All @@ -127,10 +141,11 @@ enum CLILocalHTTPRequestParseError: Error, Equatable {
case disallowedHost
}

enum CLIHTTPStatus {
enum CLIHTTPStatus: Sendable {
case ok
case badRequest
case forbidden
case unauthorized
case notFound
case methodNotAllowed
case internalServerError
Expand All @@ -140,6 +155,7 @@ enum CLIHTTPStatus {
case .ok: 200
case .badRequest: 400
case .forbidden: 403
case .unauthorized: 401
case .notFound: 404
case .methodNotAllowed: 405
case .internalServerError: 500
Expand All @@ -151,14 +167,15 @@ enum CLIHTTPStatus {
case .ok: "OK"
case .badRequest: "Bad Request"
case .forbidden: "Forbidden"
case .unauthorized: "Unauthorized"
case .notFound: "Not Found"
case .methodNotAllowed: "Method Not Allowed"
case .internalServerError: "Internal Server Error"
}
}
}

struct CLILocalHTTPResponse {
struct CLILocalHTTPResponse: Sendable {
let status: CLIHTTPStatus
let body: Data
let contentType: String
Expand Down Expand Up @@ -187,11 +204,18 @@ final class CLILocalHTTPServer {

private let host: String
private let port: UInt16
private let allowNonLoopbackHostHeaders: Bool
private let handler: Handler

init(host: String, port: UInt16, handler: @escaping Handler) {
init(
host: String,
port: UInt16,
allowNonLoopbackHostHeaders: Bool = false,
handler: @escaping Handler)
{
self.host = host
self.port = port
self.allowNonLoopbackHostHeaders = allowNonLoopbackHostHeaders
self.handler = handler
}

Expand Down Expand Up @@ -248,20 +272,25 @@ final class CLILocalHTTPServer {
let clientFD = accept(serverFD, &clientAddress, &clientLength)
guard clientFD >= 0 else { continue }
let handler = self.handler
let allowNonLoopbackHostHeaders = self.allowNonLoopbackHostHeaders
Task {
defer { closeSocket(clientFD) }
await handleClient(clientFD, handler: handler)
await handleClient(
clientFD,
allowNonLoopbackHostHeaders: allowNonLoopbackHostHeaders,
handler: handler)
}
}
}
}

private func handleClient(
_ clientFD: Int32,
allowNonLoopbackHostHeaders: Bool,
handler: @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse) async
{
let request: CLILocalHTTPRequest
switch readRequest(clientFD) {
switch readRequest(clientFD, allowNonLoopbackHostHeaders: allowNonLoopbackHostHeaders) {
case let .success(parsedRequest):
request = parsedRequest
case .failure(.disallowedHost):
Expand All @@ -284,7 +313,10 @@ private func handleClient(
sendResponse(response, to: clientFD)
}

private func readRequest(_ fd: Int32) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError> {
private func readRequest(
_ fd: Int32,
allowNonLoopbackHostHeaders: Bool) -> Result<CLILocalHTTPRequest, CLILocalHTTPRequestParseError>
{
var data = Data()
var buffer = [UInt8](repeating: 0, count: 4096)
let bufferSize = buffer.count
Expand All @@ -306,7 +338,7 @@ private func readRequest(_ fd: Int32) -> Result<CLILocalHTTPRequest, CLILocalHTT
}

guard sawHeaderEnd else { return .failure(.invalidRequest) }
return CLILocalHTTPRequest.parse(data)
return CLILocalHTTPRequest.parse(data, allowNonLoopbackHost: allowNonLoopbackHostHeaders)
}

private func sendResponse(_ response: CLILocalHTTPResponse, to fd: Int32) {
Expand Down
40 changes: 40 additions & 0 deletions Sources/CodexBarCLI/CLIServeAuth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

struct CLIServeAuth: Sendable {
let token: String?

init(token: String?) {
self.token = token
}

init(dashboardToken: String?) {
self.init(token: dashboardToken)
}

func authorizeDataRequest(_ request: CLILocalHTTPRequest) -> Bool {
guard let token else { return true }
guard let authorization = request.headers["authorization"] else { return false }
return authorization.trimmingCharacters(in: .whitespacesAndNewlines) == "Bearer \(token)"
}

func authorizeDashboardRequest(_ request: CLILocalHTTPRequest) -> Bool {
self.authorizeDataRequest(request)
}
}

enum CLIServeSecurity {
static func bindHost(_ host: String) -> String {
host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "localhost" ? "127.0.0.1" : host
}

static func isLoopbackHost(_ host: String) -> Bool {
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized == "localhost" || normalized == "::1" { return true }
if normalized.hasPrefix("127.") { return true }
return normalized == "0:0:0:0:0:0:0:1"
}

static func requiresDashboardToken(host: String) -> Bool {
!self.isLoopbackHost(host)
}
}
Loading