diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index 4dae197d7..94c6a1f44 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/telemetry" @@ -165,7 +166,13 @@ func main() { wavebase.WaveVersion = WaveVersion wavebase.BuildTime = BuildTime - err := service.ValidateServiceMap() + err := authkey.SetAuthKeyFromEnv() + if err != nil { + log.Printf("error setting auth key: %v\n", err) + return + } + + err = service.ValidateServiceMap() if err != nil { log.Printf("error validating service map: %v\n", err) return diff --git a/emain/authkey.ts b/emain/authkey.ts new file mode 100644 index 000000000..128730945 --- /dev/null +++ b/emain/authkey.ts @@ -0,0 +1,25 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints"; +import { ipcMain } from "electron"; + +const AuthKeyHeader = "X-AuthKey"; +export const AuthKeyEnv = "AUTH_KEY"; +export const AuthKey = crypto.randomUUID(); + +console.log("authKey", AuthKey); + +ipcMain.on("get-auth-key", (event) => { + event.returnValue = AuthKey; +}); + +export function configureAuthKeyRequestInjection(session: Electron.Session) { + const filter: Electron.WebRequestFilter = { + urls: [`${getWebServerEndpoint()}/*`, `${getWSServerEndpoint()}/*`], + }; + session.webRequest.onBeforeSendHeaders(filter, (details, callback) => { + details.requestHeaders[AuthKeyHeader] = AuthKey; + callback({ requestHeaders: details.requestHeaders }); + }); +} diff --git a/emain/emain.ts b/emain/emain.ts index 2383d7fb0..020d22f35 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -5,7 +5,6 @@ import * as electron from "electron"; import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; -import os from "os"; import * as path from "path"; import { PNG } from "pngjs"; import * as readline from "readline"; @@ -16,9 +15,20 @@ import winston from "winston"; import { initGlobal } from "../frontend/app/store/global"; import * as services from "../frontend/app/store/services"; import { WSServerEndpointVarName, WebServerEndpointVarName, getWebServerEndpoint } from "../frontend/util/endpoints"; -import { WaveDevVarName, WaveDevViteVarName } from "../frontend/util/isdev"; -import * as keyutil from "../frontend/util/keyutil"; +import { fetch } from "../frontend/util/fetchutil"; import { fireAndForget } from "../frontend/util/util"; +import { AuthKey, AuthKeyEnv, configureAuthKeyRequestInjection } from "./authkey"; +import { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +} from "./platform"; import { configureAutoUpdater, updater } from "./updater"; const electronApp = electron.app; @@ -27,7 +37,6 @@ let WaveBuildTime = 0; // set by WAVESRV-ESTART const WaveAppPathVarName = "WAVETERM_APP_PATH"; const WaveSrvReadySignalPidVarName = "WAVETERM_READY_SIGNAL_PID"; -const AuthKeyFile = "waveterm.authkey"; electron.nativeTheme.themeSource = "dark"; type WaveBrowserWindow = Electron.BrowserWindow & { waveWindowId: string; readyPromise: Promise }; @@ -44,35 +53,14 @@ let globalIsRelaunching = false; let wasActive = true; let wasInFg = true; -const isDev = !electronApp.isPackaged; -const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; -if (isDev) { - process.env[WaveDevVarName] = "1"; -} -if (isDevVite) { - process.env[WaveDevViteVarName] = "1"; -} - let waveSrvProc: child_process.ChildProcessWithoutNullStreams | null = null; -electronApp.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave"); -const unamePlatform = process.platform; -let unameArch: string = process.arch; -if (unameArch == "x64") { - unameArch = "amd64"; -} -keyutil.setKeyUtilPlatform(unamePlatform); - -// must match golang -function getWaveHomeDir() { - return path.join(os.homedir(), isDev ? ".w2-dev" : ".w2"); -} const waveHome = getWaveHomeDir(); const oldConsoleLog = console.log; const loggerTransports: winston.transport[] = [ - new winston.transports.File({ filename: path.join(waveHome, "waveterm-app.log"), level: "info" }), + new winston.transports.File({ filename: path.join(getWaveHomeDir(), "waveterm-app.log"), level: "info" }), ]; if (isDev) { loggerTransports.push(new winston.transports.Console()); @@ -110,29 +98,6 @@ if (isDev) { initGlobal({ windowId: null, clientId: null, platform: unamePlatform, environment: "electron" }); -function getElectronAppBasePath(): string { - return path.dirname(__dirname); -} - -function getGoAppBasePath(): string { - return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); -} - -const wavesrvBinName = `wavesrv.${unameArch}`; - -function getWaveSrvPath(): string { - if (process.platform === "win32") { - const winBinName = `${wavesrvBinName}.exe`; - const appPath = path.join(getGoAppBasePath(), "bin", winBinName); - return `${appPath}`; - } - return path.join(getGoAppBasePath(), "bin", wavesrvBinName); -} - -function getWaveSrvCwd(): string { - return getWaveHomeDir(); -} - function getWindowForEvent(event: Electron.IpcMainEvent): Electron.BrowserWindow { const windowId = event.sender.id; return electron.BrowserWindow.fromId(windowId); @@ -148,6 +113,7 @@ function runWaveSrv(): Promise { const envCopy = { ...process.env }; envCopy[WaveAppPathVarName] = getGoAppBasePath(); envCopy[WaveSrvReadySignalPidVarName] = process.pid.toString(); + envCopy[AuthKeyEnv] = AuthKey; const waveSrvCmd = getWaveSrvPath(); console.log("trying to run local server", waveSrvCmd); const proc = child_process.spawn(getWaveSrvPath(), { @@ -197,6 +163,7 @@ function runWaveSrv(): Promise { applicationVersion: "v" + WaveVersion, version: (isDev ? "dev-" : "") + String(WaveBuildTime), }); + configureAuthKeyRequestInjection(electron.session.defaultSession); waveSrvReadyResolve(true); return; } @@ -465,6 +432,7 @@ function createBrowserWindow( console.log("window-open denied", url); return { action: "deny" }; }); + configureAuthKeyRequestInjection(win.webContents.session); return win; } @@ -548,12 +516,6 @@ function ensureBoundsAreVisible(bounds: electron.Rectangle): electron.Rectangle return bounds; } -electron.ipcMain.on("getIsDev", (event) => { - event.returnValue = isDev; -}); -electron.ipcMain.on("getPlatform", (event, url) => { - event.returnValue = unamePlatform; -}); // Listen for the open-external event from the renderer process electron.ipcMain.on("open-external", (event, url) => { if (url && typeof url === "string") { @@ -571,7 +533,7 @@ electron.ipcMain.on("download", (event, payload) => { window.webContents.downloadURL(streamingUrl); }); -electron.ipcMain.on("getCursorPoint", (event) => { +electron.ipcMain.on("get-cursor-point", (event) => { const window = electron.BrowserWindow.fromWebContents(event.sender); const screenPoint = electron.screen.getCursorScreenPoint(); const windowRect = window.getContentBounds(); @@ -582,7 +544,7 @@ electron.ipcMain.on("getCursorPoint", (event) => { event.returnValue = retVal; }); -electron.ipcMain.on("getEnv", (event, varName) => { +electron.ipcMain.on("get-env", (event, varName) => { event.returnValue = process.env[varName] ?? null; }); @@ -617,7 +579,7 @@ async function createNewWaveWindow() { newBrowserWindow.show(); } -electron.ipcMain.on("openNewWindow", () => fireAndForget(createNewWaveWindow)); +electron.ipcMain.on("open-new-window", () => fireAndForget(createNewWaveWindow)); electron.ipcMain.on("contextmenu-show", (event, menuDefArr?: ElectronContextMenuItem[]) => { const window = electron.BrowserWindow.fromWebContents(event.sender); diff --git a/emain/platform.ts b/emain/platform.ts new file mode 100644 index 000000000..d9a2e8893 --- /dev/null +++ b/emain/platform.ts @@ -0,0 +1,69 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { WaveDevVarName, WaveDevViteVarName } from "@/util/isdev"; +import { app, ipcMain } from "electron"; +import os from "os"; +import path from "path"; +import * as keyutil from "../frontend/util/keyutil"; + +const isDev = !app.isPackaged; +const isDevVite = isDev && process.env.ELECTRON_RENDERER_URL; +if (isDev) { + process.env[WaveDevVarName] = "1"; +} +if (isDevVite) { + process.env[WaveDevViteVarName] = "1"; +} + +app.setName(isDev ? "TheNextWave (Dev)" : "TheNextWave"); +const unamePlatform = process.platform; +const unameArch: string = process.arch === "x64" ? "amd64" : process.arch; +keyutil.setKeyUtilPlatform(unamePlatform); + +ipcMain.on("get-is-dev", (event) => { + event.returnValue = isDev; +}); +ipcMain.on("get-platform", (event, url) => { + event.returnValue = unamePlatform; +}); + +// must match golang +function getWaveHomeDir() { + return path.join(os.homedir(), isDev ? ".w2-dev" : ".w2"); +} + +function getElectronAppBasePath(): string { + return path.dirname(__dirname); +} + +function getGoAppBasePath(): string { + return getElectronAppBasePath().replace("app.asar", "app.asar.unpacked"); +} + +const wavesrvBinName = `wavesrv.${unameArch}`; + +function getWaveSrvPath(): string { + if (process.platform === "win32") { + const winBinName = `${wavesrvBinName}.exe`; + const appPath = path.join(getGoAppBasePath(), "bin", winBinName); + return `${appPath}`; + } + return path.join(getGoAppBasePath(), "bin", wavesrvBinName); +} + +function getWaveSrvCwd(): string { + return getWaveHomeDir(); +} + +export { + getElectronAppBasePath, + getGoAppBasePath, + getWaveHomeDir, + getWaveSrvCwd, + getWaveSrvPath, + isDev, + isDevVite, + unameArch, + unamePlatform, +}; diff --git a/emain/preload.ts b/emain/preload.ts index 77d44dc6a..19d33848a 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -4,10 +4,11 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("api", { - getIsDev: () => ipcRenderer.sendSync("getIsDev"), - getPlatform: () => ipcRenderer.sendSync("getPlatform"), - getCursorPoint: () => ipcRenderer.sendSync("getCursorPoint"), - openNewWindow: () => ipcRenderer.send("openNewWindow"), + getAuthKey: () => ipcRenderer.sendSync("get-auth-key"), + getIsDev: () => ipcRenderer.sendSync("get-is-dev"), + getPlatform: () => ipcRenderer.sendSync("get-platform"), + getCursorPoint: () => ipcRenderer.sendSync("get-cursor-point"), + openNewWindow: () => ipcRenderer.send("open-new-window"), showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position), onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", (_event, id) => callback(id)), downloadFile: (filePath) => ipcRenderer.send("download", { filePath }), @@ -18,7 +19,7 @@ contextBridge.exposeInMainWorld("api", { console.error("Invalid URL passed to openExternal:", url); } }, - getEnv: (varName) => ipcRenderer.sendSync("getEnv", varName), + getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), onUpdaterStatusChange: (callback) => ipcRenderer.on("app-update-status", (_event, status) => callback(status)), diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 697ac94d0..a929b45df 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -10,6 +10,7 @@ import { newLayoutNode, } from "@/layout/index"; import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; import { produce } from "immer"; import * as jotai from "jotai"; import * as rxjs from "rxjs"; diff --git a/frontend/app/store/wos.ts b/frontend/app/store/wos.ts index 7aa45fc66..49bf89018 100644 --- a/frontend/app/store/wos.ts +++ b/frontend/app/store/wos.ts @@ -5,6 +5,7 @@ import { sendRpcCommand } from "@/app/store/wshrpc"; import { getWebServerEndpoint } from "@/util/endpoints"; +import { fetch } from "@/util/fetchutil"; import * as jotai from "jotai"; import * as React from "react"; import { atoms, globalStore } from "./global"; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 877685dbe..29609c47f 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -52,6 +52,7 @@ declare global { }; type ElectronApi = { + getAuthKey(): string; getIsDev(): boolean; getCursorPoint: () => Electron.Point; diff --git a/frontend/util/fetchutil.ts b/frontend/util/fetchutil.ts new file mode 100644 index 000000000..59dd68e02 --- /dev/null +++ b/frontend/util/fetchutil.ts @@ -0,0 +1,20 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Utility to abstract the fetch function so the Electron net module can be used when available. + +let net: Electron.Net; + +try { + import("electron").then(({ net: electronNet }) => (net = electronNet)); +} catch (e) { + // do nothing +} + +export function fetch(input: string | GlobalRequest | URL, init?: RequestInit): Promise { + if (net) { + return net.fetch(input.toString(), init); + } else { + return globalThis.fetch(input, init); + } +} diff --git a/pkg/authkey/authkey.go b/pkg/authkey/authkey.go new file mode 100644 index 000000000..26ec4a6f7 --- /dev/null +++ b/pkg/authkey/authkey.go @@ -0,0 +1,38 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package authkey + +import ( + "fmt" + "net/http" + "os" +) + +var authkey string + +const AuthKeyEnv = "AUTH_KEY" + +func SetAuthKeyFromEnv() error { + authkey = os.Getenv(AuthKeyEnv) + if authkey == "" { + return fmt.Errorf("no auth key found in environment variables") + } + os.Setenv(AuthKeyEnv, "") + return nil +} + +func GetAuthKey() string { + return authkey +} + +func ValidateIncomingRequest(r *http.Request) error { + reqAuthKey := r.Header.Get("X-AuthKey") + if reqAuthKey == "" { + return fmt.Errorf("no x-authkey header") + } + if reqAuthKey != GetAuthKey() { + return fmt.Errorf("x-authkey header is invalid") + } + return nil +} diff --git a/pkg/web/web.go b/pkg/web/web.go index d2335c351..eccd18938 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/filestore" "github.com/wavetermdev/thenextwave/pkg/service" "github.com/wavetermdev/thenextwave/pkg/telemetry" @@ -397,17 +398,13 @@ func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { w.Header().Set(CacheControlHeaderKey, CacheControlHeaderNoCache) } w.Header().Set("Access-Control-Expose-Headers", "X-ZoneFileInfo") - // reqAuthKey := r.Header.Get("X-AuthKey") - // if reqAuthKey == "" { - // w.WriteHeader(http.StatusInternalServerError) - // w.Write([]byte("no x-authkey header")) - // return - // } - // if reqAuthKey != scbase.WaveAuthKey { - // w.WriteHeader(http.StatusInternalServerError) - // w.Write([]byte("x-authkey header is invalid")) - // return - // } + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + log.Printf("error validating request: %v", err) + return + } fn(w, r) } } diff --git a/pkg/web/ws.go b/pkg/web/ws.go index fa335631d..267c9f275 100644 --- a/pkg/web/ws.go +++ b/pkg/web/ws.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/websocket" + "github.com/wavetermdev/thenextwave/pkg/authkey" "github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/web/webcmd" "github.com/wavetermdev/thenextwave/pkg/wshrpc" @@ -244,6 +245,14 @@ func HandleWsInternal(w http.ResponseWriter, r *http.Request) error { if windowId == "" { return fmt.Errorf("windowid is required") } + + err := authkey.ValidateIncomingRequest(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("error validating authkey: %v", err))) + log.Printf("error validating request: %v", err) + return err + } conn, err := WebSocketUpgrader.Upgrade(w, r, nil) if err != nil { return fmt.Errorf("WebSocket Upgrade Failed: %v", err)