diff --git a/Dockerfile b/Dockerfile index 6a6b550fec..2f96199a52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ # docker build -t kandev:latest . # # Run: -# docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data kandev:latest +# docker run -p 8080:8080 -v kandev-data:/data kandev:latest # --------------------------------------------------------------------------- # Stage 1: Go builder — compile kandev + agentctl binaries @@ -110,7 +110,7 @@ RUN chown -R kandev:kandev /app /data WORKDIR /app -EXPOSE 8080 3000 +EXPOSE 8080 # tini as PID 1 for signal handling; entrypoint handles privilege drop ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 514314c035..513d3d03b2 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -3,6 +3,9 @@ package main import ( "context" "net/http" + "net/http/httputil" + "net/url" + "strings" "time" "github.com/gin-gonic/gin" @@ -383,6 +386,7 @@ type routeParams struct { msgCreator *messageCreatorAdapter secretsSvc *secrets.Service secretStore secrets.SecretStore + webInternalURL string log *logger.Logger } @@ -401,6 +405,33 @@ func registerRoutes(p routeParams) { p.router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"}) }) + + if p.webInternalURL != "" { + target, err := url.Parse(p.webInternalURL) + if err != nil { + p.log.Error("Invalid web internal URL, skipping reverse proxy", zap.String("url", p.webInternalURL), zap.Error(err)) + } else { + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.FlushInterval = -1 + // Custom error handler to prevent the default behavior of panicking + // with http.ErrAbortHandler when the upstream connection fails or the + // client disconnects. Without this, Gin's recovery middleware catches + // the panic and logs a full (harmless but noisy) stack trace. + proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + p.log.Debug("Web proxy error", zap.Error(err)) + w.WriteHeader(http.StatusBadGateway) + } + p.router.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" { + c.AbortWithStatus(http.StatusNotFound) + return + } + proxy.ServeHTTP(c.Writer, c.Request) + }) + p.log.Info("Web reverse proxy enabled", zap.String("target", p.webInternalURL)) + } + } } // registerTaskRoutes registers all task-related HTTP and WebSocket routes. diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 22526960e7..f7bf9c5764 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -432,6 +432,7 @@ func buildHTTPServer( msgCreator: msgCreator, secretsSvc: secrets.NewService(repos.Secrets, log), secretStore: repos.Secrets, + webInternalURL: cfg.Server.WebInternalURL, log: log, }) diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 82cf47c5a1..b505917086 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -50,10 +50,11 @@ func (c *Config) ResolvedDataDir() string { // ServerConfig holds HTTP server configuration. type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - ReadTimeout int `mapstructure:"readTimeout"` // in seconds - WriteTimeout int `mapstructure:"writeTimeout"` // in seconds + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout int `mapstructure:"readTimeout"` // in seconds + WriteTimeout int `mapstructure:"writeTimeout"` // in seconds + WebInternalURL string `mapstructure:"webInternalUrl"` } // DatabaseConfig holds database connection configuration. @@ -197,6 +198,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("server.port", 8080) v.SetDefault("server.readTimeout", 30) v.SetDefault("server.writeTimeout", 30) + v.SetDefault("server.webInternalUrl", "") // DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data v.SetDefault("dataDir", "") @@ -312,6 +314,7 @@ func LoadWithPath(configPath string) (*Config, error) { _ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST") _ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT") _ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL") + _ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL") _ = v.BindEnv("dataDir", "KANDEV_DATA_DIR") _ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL") _ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE") diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts index 2e56f96388..3f3e1c7f16 100644 --- a/apps/cli/src/dev.ts +++ b/apps/cli/src/dev.ts @@ -28,7 +28,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true", }; const backendEnv = buildBackendEnv({ ports, extra }); - const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true }); + const webEnv = buildWebEnv({ ports, debug: true }); const logLevel = process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info"; @@ -36,7 +36,6 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr header: "dev mode: using local repo", ports, dbPath, - includeMcp: true, logLevel, }); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index cc6bd0ba40..60bf44ccb6 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -174,6 +174,7 @@ async function prepareReleaseBundle({ const backendEnv = { ...process.env, KANDEV_SERVER_PORT: String(actualBackendPort), + KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`, KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), KANDEV_DATABASE_PATH: dbPath, KANDEV_LOG_LEVEL: logLevel, @@ -182,7 +183,6 @@ async function prepareReleaseBundle({ const webEnv: NodeJS.ProcessEnv = { ...process.env, KANDEV_API_BASE_URL: backendUrl, - NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl, PORT: String(actualWebPort), }; (webEnv as Record).NODE_ENV = "production"; @@ -264,6 +264,6 @@ export async function runRelease({ version, backendPort, webPort }: RunOptions): quiet: true, }); await waitForUrlReady(webUrl, webProc, healthTimeoutMs); - console.log(`[kandev] web ready at ${webUrl}`); - openBrowser(webUrl); + console.log("[kandev] ready at " + prepared.backendUrl); + openBrowser(prepared.backendUrl); } diff --git a/apps/cli/src/shared.ts b/apps/cli/src/shared.ts index 4a6b8b6431..b8d1e4d7f5 100644 --- a/apps/cli/src/shared.ts +++ b/apps/cli/src/shared.ts @@ -67,6 +67,7 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv { return { ...process.env, KANDEV_SERVER_PORT: String(ports.backendPort), + KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}`, KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort), KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort), ...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}), @@ -76,8 +77,6 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv { export type WebEnvOptions = { ports: PortConfig; - /** Include MCP URL environment variables (used in dev/start modes) */ - includeMcp?: boolean; /** Set NODE_ENV to production */ production?: boolean; /** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */ @@ -91,25 +90,18 @@ export type WebEnvOptions = { * @returns Environment object for the web process */ export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv { - const { ports, includeMcp = false, production = false, debug = false } = options; + const { ports, production = false, debug = false } = options; - // Server-side env vars use localhost (SSR runs on same machine as backend) - // Client-side uses NEXT_PUBLIC_*_PORT to build URLs dynamically from window.location.hostname - // This allows accessing the app from any device (iPhone, Tailscale, etc.) const env: NodeJS.ProcessEnv = { ...process.env, - // Server-side: full localhost URL for SSR + // Server-side: full localhost URL for SSR fetches (Next.js SSR → Go backend) KANDEV_API_BASE_URL: ports.backendUrl, - // Client-side: only pass ports, client builds URL from current hostname + // Client-side: port injection for dev mode (browser on :3000, API on :8080). + // In production, the backend reverse-proxies Next.js so the client uses same-origin. NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort), PORT: String(ports.webPort), }; - if (includeMcp) { - env.KANDEV_MCP_SERVER_URL = ports.mcpUrl; - env.NEXT_PUBLIC_KANDEV_MCP_PORT = String(ports.mcpPort); - } - if (production) { (env as Record).NODE_ENV = "production"; } @@ -127,8 +119,6 @@ export type StartupInfoOptions = { ports: PortConfig; /** Database file path */ dbPath?: string; - /** Whether to log MCP-related ports */ - includeMcp?: boolean; /** Log level being used */ logLevel?: string; }; @@ -137,15 +127,12 @@ export type StartupInfoOptions = { * Logs a unified startup info block to the console. */ export function logStartupInfo(options: StartupInfoOptions): void { - const { header, ports, dbPath, includeMcp = false, logLevel } = options; + const { header, ports, dbPath, logLevel } = options; console.log(`[kandev] ${header}`); - console.log("[kandev] backend port:", ports.backendPort); - console.log("[kandev] web port:", ports.webPort); + console.log("[kandev] url: http://localhost:" + ports.backendPort); console.log("[kandev] agentctl port:", ports.agentctlPort); - if (includeMcp) { - console.log("[kandev] mcp port:", ports.mcpPort); - console.log("[kandev] mcp url:", ports.mcpUrl); - } + console.log("[kandev] mcp port:", ports.mcpPort); + console.log("[kandev] mcp url:", ports.mcpUrl); if (dbPath) { console.log("[kandev] db path:", dbPath); } diff --git a/apps/cli/src/start.ts b/apps/cli/src/start.ts index 131ffdcd95..c36ea7f898 100644 --- a/apps/cli/src/start.ts +++ b/apps/cli/src/start.ts @@ -121,13 +121,12 @@ export async function runStart({ ...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}), }, }); - const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug }); + const webEnv = buildWebEnv({ ports, production: true, debug }); logStartupInfo({ header: "start mode: using local build", ports, dbPath, - includeMcp: true, logLevel, }); @@ -168,6 +167,6 @@ export async function runStart({ }); await waitForUrlReady(webUrl, webProc, healthTimeoutMs); - console.log(`[kandev] web ready at ${webUrl}`); - openBrowser(webUrl); + console.log("[kandev] ready at " + ports.backendUrl); + openBrowser(ports.backendUrl); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 634525e9bb..1b335e41da 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -24,31 +24,15 @@ export const viewport: Viewport = { userScalable: false, }; -/** - * Extract port from URL string (e.g., "http://localhost:8080" -> "8080") - */ -function extractPort(url: string): string | null { - try { - const parsed = new URL(url); - return parsed.port || null; - } catch { - return null; - } -} - export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - // Runtime URL configuration for the browser client. - // KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress - // (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL. - // Otherwise: extract ports so client builds URLs from window.location.hostname, - // allowing access from any device (iPhone, Tailscale, etc.) - const publicUrl = process.env.KANDEV_PUBLIC_URL?.replace(/\/$/, "") || null; - const apiPort = publicUrl ? null : extractPort(process.env.KANDEV_API_BASE_URL ?? ""); - const mcpPort = extractPort(process.env.KANDEV_MCP_SERVER_URL ?? ""); + // API port injection for dev mode (browser opens at web port, API on backend port). + // In production single-port mode, this is not needed — the client uses + // window.location.origin (same-origin, works for any domain / reverse proxy). + const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null; const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true"; return ( @@ -57,16 +41,11 @@ export default function RootLayout({ - {publicUrl || apiPort || mcpPort || debugMode ? ( - // Inject runtime config for production bundles. - // publicUrl: full base URL for reverse-proxy deployments (same-origin API) - // ports: client builds URLs using window.location.hostname + port + {apiPort || debugMode ? (