From 1aa5eb83ed47d1ce554102c432db08e759bf17da Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 28 Nov 2025 16:42:10 -0800 Subject: [PATCH 01/29] add a backend config update system (easier than wps) --- go.mod | 1 + go.sum | 2 ++ pkg/wconfig/filewatcher.go | 23 ++++++++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ef1927874f..6711ded66a 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/outrigdev/goid v0.3.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/go.sum b/go.sum index ee73e96e87..ea416cd09a 100644 --- a/go.sum +++ b/go.sum @@ -146,6 +146,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/outrigdev/goid v0.3.0 h1:t/otQD3EXc45cLtQVPUnNgEyRaTQA4cPeu3qVcrsIws= +github.com/outrigdev/goid v0.3.0/go.mod h1:hEH7f27ypN/GHWt/7gvkRoFYR0LZizfUBIAbak4neVE= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b h1:cLGKfKb1uk0hxI0Q8L83UAJPpeJ+gSpn3cCU/tjd3eg= github.com/photostorm/pty v1.1.19-0.20230903182454-31354506054b/go.mod h1:KO+FcPtyLAiRC0hJwreJVvfwc7vnNz77UxBTIGHdPVk= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index ec4916cdf4..40a008d921 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -18,11 +18,14 @@ import ( var instance *Watcher var once sync.Once +type ConfigUpdateHandler func(FullConfigType) + type Watcher struct { initialized bool watcher *fsnotify.Watcher mutex sync.Mutex fullConfig FullConfigType + handlers []ConfigUpdateHandler } type WatcherUpdate struct { @@ -106,11 +109,29 @@ func (w *Watcher) Close() { } func (w *Watcher) broadcast(message WatcherUpdate) { - // send to frontend wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_Config, Data: message, }) + w.notifyHandlers(message.FullConfig) +} + +func (w *Watcher) RegisterUpdateHandler(handler ConfigUpdateHandler) { + w.mutex.Lock() + defer w.mutex.Unlock() + w.handlers = append(w.handlers, handler) +} + +func (w *Watcher) notifyHandlers(config FullConfigType) { + handlers := w.handlers + for _, handler := range handlers { + go func(h ConfigUpdateHandler) { + defer func() { + panichandler.PanicHandler("filewatcher:notifyHandlers", recover()) + }() + h(config) + }(handler) + } } func (w *Watcher) GetFullConfig() FullConfigType { From 39164ea30cf53144b7978619e13008982b065498 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 28 Nov 2025 16:55:11 -0800 Subject: [PATCH 02/29] add no telemetry watcher + add panic handlers --- cmd/server/main-server.go | 53 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b52d97491c..ac59ad792f 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -91,6 +91,9 @@ func doShutdown(reason string) { // watch stdin, kill server if stdin is closed func stdinReadWatch() { + defer func() { + panichandler.PanicHandler("stdinReadWatch", recover()) + }() buf := make([]byte, 1024) for { _, err := os.Stdin.Read(buf) @@ -109,6 +112,9 @@ func startConfigWatcher() { } func telemetryLoop() { + defer func() { + panichandler.PanicHandler("telemetryLoop", recover()) + }() var nextSend int64 time.Sleep(InitialTelemetryWait) for { @@ -120,6 +126,42 @@ func telemetryLoop() { } } +func sendNoTelemetryUpdate(telemetryEnabled bool) { + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) + if err != nil { + log.Printf("telemetry update: error getting client data: %v\n", err) + return + } + if clientData == nil { + log.Printf("telemetry update: client data is nil\n") + return + } + err = wcloud.SendNoTelemetryUpdate(ctx, clientData.OID, !telemetryEnabled) + if err != nil { + log.Printf("[error] sending no-telemetry update: %v\n", err) + return + } +} + +func setupTelemetryConfigHandler() { + watcher := wconfig.GetWatcher() + if watcher == nil { + return + } + currentConfig := watcher.GetFullConfig() + currentTelemetryEnabled := currentConfig.Settings.TelemetryEnabled + + watcher.RegisterUpdateHandler(func(newConfig wconfig.FullConfigType) { + newTelemetryEnabled := newConfig.Settings.TelemetryEnabled + if newTelemetryEnabled != currentTelemetryEnabled { + currentTelemetryEnabled = newTelemetryEnabled + go sendNoTelemetryUpdate(newTelemetryEnabled) + } + }) +} + func backupCleanupLoop() { defer func() { panichandler.PanicHandler("backupCleanupLoop", recover()) @@ -232,6 +274,9 @@ func beforeSendActivityUpdate(ctx context.Context) { } func startupActivityUpdate(firstLaunch bool) { + defer func() { + panichandler.PanicHandler("startupActivityUpdate", recover()) + }() ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() activity := wshrpc.ActivityUpdate{Startup: 1} @@ -472,11 +517,17 @@ func main() { maybeStartPprofServer() go stdinReadWatch() go telemetryLoop() + setupTelemetryConfigHandler() go updateTelemetryCountsLoop() go backupCleanupLoop() go startupActivityUpdate(firstLaunch) // must be after startConfigWatcher() blocklogger.InitBlockLogger() - go wavebase.GetSystemSummary() // get this cached (used in AI) + go func() { + defer func() { + panichandler.PanicHandler("GetSystemSummary", recover()) + }() + wavebase.GetSystemSummary() + }() webListener, err := web.MakeTCPListener("web") if err != nil { From 9f4db4e7868499eb420bd01d81b11d43aa24edd2 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 28 Nov 2025 17:52:29 -0800 Subject: [PATCH 03/29] ssh password from secret store --- frontend/app/modals/conntypeahead.tsx | 5 +- frontend/app/modals/userinputmodal.scss | 62 ----------------- frontend/app/modals/userinputmodal.tsx | 20 +++--- frontend/types/gotypes.d.ts | 1 + pkg/remote/sshclient.go | 28 +++++++- pkg/wconfig/settingsconfig.go | 1 + schema/bgpresets.json | 88 ++++++++++++------------- schema/connections.json | 3 + 8 files changed, 87 insertions(+), 121 deletions(-) delete mode 100644 frontend/app/modals/userinputmodal.scss diff --git a/frontend/app/modals/conntypeahead.tsx b/frontend/app/modals/conntypeahead.tsx index bee43cb03d..b5e21c2257 100644 --- a/frontend/app/modals/conntypeahead.tsx +++ b/frontend/app/modals/conntypeahead.tsx @@ -272,11 +272,10 @@ function getConnectionsEditItem( onSelect: () => { util.fireAndForget(async () => { globalStore.set(changeConnModalAtom, false); - const path = `${getApi().getConfigDir()}/connections.json`; const blockDef: BlockDef = { meta: { - view: "preview", - file: path, + view: "waveconfig", + file: "connections.json", }, }; await createBlock(blockDef, false, true); diff --git a/frontend/app/modals/userinputmodal.scss b/frontend/app/modals/userinputmodal.scss deleted file mode 100644 index c630422cbc..0000000000 --- a/frontend/app/modals/userinputmodal.scss +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2024, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -.userinput-header { - font-weight: bold; - color: var(--main-text-color); - padding-bottom: 10px; -} - -.userinput-body { - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 1rem; - margin: 0 1rem 1rem 1rem; - max-width: 500px; - - font: var(--fixed-font); - color: var(--main-text-color); - - .userinput-markdown { - color: inherit; - } - - .userinput-text { - } - - .userinput-inputbox { - resize: none; - background-color: var(--panel-bg-color); - border-radius: 6px; - margin: 0; - border: var(--border-color); - padding: 5px 0 5px 16px; - min-height: 30px; - color: inherit; - - &:hover { - cursor: text; - } - - &:focus { - outline-color: var(--accent-color); - } - } - - .userinput-checkbox-container { - display: flex; - flex-direction: column; - gap: 6px; - - .userinput-checkbox-row { - display: flex; - align-items: center; - gap: 6px; - - .userinput-checkbox { - accent-color: var(--accent-color); - } - } - } -} diff --git a/frontend/app/modals/userinputmodal.tsx b/frontend/app/modals/userinputmodal.tsx index d277a73236..e8c3574c29 100644 --- a/frontend/app/modals/userinputmodal.tsx +++ b/frontend/app/modals/userinputmodal.tsx @@ -8,7 +8,6 @@ import * as keyutil from "@/util/keyutil"; import { fireAndForget } from "@/util/util"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { UserInputService } from "../store/services"; -import "./userinputmodal.scss"; const UserInputModal = (userInputRequest: UserInputRequest) => { const [responseText, setResponseText] = useState(""); @@ -80,9 +79,9 @@ const UserInputModal = (userInputRequest: UserInputRequest) => { const queryText = useMemo(() => { if (userInputRequest.markdown) { - return ; + return ; } - return {userInputRequest.querytext}; + return {userInputRequest.querytext}; }, [userInputRequest.markdown, userInputRequest.querytext]); const inputBox = useMemo(() => { @@ -95,7 +94,7 @@ const UserInputModal = (userInputRequest: UserInputRequest) => { onChange={(e) => setResponseText(e.target.value)} value={responseText} maxLength={400} - className="userinput-inputbox" + className="resize-none bg-panel rounded-md border border-border py-1.5 pl-4 min-h-[30px] text-inherit cursor-text focus:outline-accent" autoFocus={true} onKeyDown={(e) => keyutil.keydownWrapper(handleKeyDown)(e)} /> @@ -107,15 +106,15 @@ const UserInputModal = (userInputRequest: UserInputRequest) => { return <>; } return ( -
-
+
+
- +
); @@ -148,14 +147,15 @@ const UserInputModal = (userInputRequest: UserInputRequest) => { return ( handleSubmit()} onCancel={() => handleNegativeResponse()} onClose={() => handleSendErrResponse()} okLabel={userInputRequest.oklabel} cancelLabel={userInputRequest.cancellabel} > -
{userInputRequest.title + ` (${countdown}s)`}
-
+
{userInputRequest.title + ` (${countdown}s)`}
+
{queryText} {inputBox} {optionalCheckbox} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4c30d15bba..5cd27efee7 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -606,6 +606,7 @@ declare global { "ssh:hostname"?: string; "ssh:port"?: string; "ssh:identityfile"?: string[]; + "ssh:passwordsecretname"?: string; "ssh:batchmode"?: boolean; "ssh:pubkeyauthentication"?: boolean; "ssh:passwordauthentication"?: boolean; diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 30862b15d7..c7419fd940 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -25,6 +25,7 @@ import ( "github.com/skeema/knownhosts" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/secretstore" "github.com/wavetermdev/waveterm/pkg/trimquotes" "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" @@ -223,7 +224,7 @@ func createPublicKeyCallback(connCtx context.Context, sshKeywords *wconfig.ConnK } } -func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) { +func createPasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string, password *string, debugInfo *ConnectionDebugInfo) func() (secret string, err error) { return func() (secret string, outErr error) { defer func() { panicErr := panichandler.PanicHandler("sshclient:password-callback", recover()) @@ -232,6 +233,12 @@ func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisp } }() blocklogger.Infof(connCtx, "[conndebug] Password Authentication requested from connection %s...\n", remoteDisplayName) + + if password != nil { + blocklogger.Infof(connCtx, "[conndebug] using password from secret store, sending to ssh\n") + return *password, nil + } + ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second) defer cancelFn() queryText := fmt.Sprintf( @@ -615,9 +622,23 @@ func createClientConfig(connCtx context.Context, sshKeywords *wconfig.ConnKeywor } } + var sshPassword *string + if sshKeywords.SshPasswordSecretName != nil && *sshKeywords.SshPasswordSecretName != "" { + secretName := *sshKeywords.SshPasswordSecretName + password, exists, err := secretstore.GetSecret(secretName) + if err != nil { + return nil, fmt.Errorf("error retrieving ssh:passwordsecretname %q: %w", secretName, err) + } + if !exists { + return nil, fmt.Errorf("ssh:passwordsecretname %q not found in secret store", secretName) + } + blocklogger.Infof(connCtx, "[conndebug] successfully retrieved ssh:passwordsecretname %q from secret store\n", secretName) + sshPassword = &password + } + publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, authSockSigners, agentClient, debugInfo)) keyboardInteractive := ssh.KeyboardInteractive(createInteractiveKbdInteractiveChallenge(connCtx, remoteName, debugInfo)) - passwordCallback := ssh.PasswordCallback(createInteractivePasswordCallbackPrompt(connCtx, remoteName, debugInfo)) + passwordCallback := ssh.PasswordCallback(createPasswordCallbackPrompt(connCtx, remoteName, sshPassword, debugInfo)) // exclude gssapi-with-mic and hostbased until implemented authMethodMap := map[string]ssh.AuthMethod{ @@ -1014,6 +1035,9 @@ func mergeKeywords(oldKeywords *wconfig.ConnKeywords, newKeywords *wconfig.ConnK if newKeywords.SshGlobalKnownHostsFile != nil { outKeywords.SshGlobalKnownHostsFile = newKeywords.SshGlobalKnownHostsFile } + if newKeywords.SshPasswordSecretName != nil { + outKeywords.SshPasswordSecretName = newKeywords.SshPasswordSecretName + } return &outKeywords } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index a034d42140..2d0d8cd3fc 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -315,6 +315,7 @@ type ConnKeywords struct { SshHostName *string `json:"ssh:hostname,omitempty"` SshPort *string `json:"ssh:port,omitempty"` SshIdentityFile []string `json:"ssh:identityfile,omitempty"` + SshPasswordSecretName *string `json:"ssh:passwordsecretname,omitempty"` SshBatchMode *bool `json:"ssh:batchmode,omitempty"` SshPubkeyAuthentication *bool `json:"ssh:pubkeyauthentication,omitempty"` SshPasswordAuthentication *bool `json:"ssh:passwordauthentication,omitempty"` diff --git a/schema/bgpresets.json b/schema/bgpresets.json index d9c9bf9e5c..3ab0a8a433 100644 --- a/schema/bgpresets.json +++ b/schema/bgpresets.json @@ -1,46 +1,46 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "BgPresetsType": { - "properties": { - "bg:*": { - "type": "boolean" - }, - "bg": { - "type": "string", - "description": "CSS background property value" - }, - "bg:opacity": { - "type": "number", - "description": "Background opacity (0.0-1.0)" - }, - "bg:blendmode": { - "type": "string", - "description": "CSS background-blend-mode property value" - }, - "bg:bordercolor": { - "type": "string", - "description": "Block frame border color" - }, - "bg:activebordercolor": { - "type": "string", - "description": "Block frame focused border color" - }, - "display:name": { - "type": "string", - "description": "The name shown in the context menu" - }, - "display:order": { - "type": "number", - "description": "Determines the order of the background in the context menu" - } - }, - "additionalProperties": false, - "type": "object" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "BgPresetsType": { + "properties": { + "bg:*": { + "type": "boolean" + }, + "bg": { + "type": "string", + "description": "CSS background property value" + }, + "bg:opacity": { + "type": "number", + "description": "Background opacity (0.0-1.0)" + }, + "bg:blendmode": { + "type": "string", + "description": "CSS background-blend-mode property value" + }, + "bg:bordercolor": { + "type": "string", + "description": "Block frame border color" + }, + "bg:activebordercolor": { + "type": "string", + "description": "Block frame focused border color" + }, + "display:name": { + "type": "string", + "description": "The name shown in the context menu" + }, + "display:order": { + "type": "number", + "description": "Determines the order of the background in the context menu" } - }, - "additionalProperties": { - "$ref": "#/$defs/BgPresetsType" - }, - "type": "object" -} + }, + "additionalProperties": false, + "type": "object" + } + }, + "additionalProperties": { + "$ref": "#/$defs/BgPresetsType" + }, + "type": "object" +} \ No newline at end of file diff --git a/schema/connections.json b/schema/connections.json index 014a569dd7..cdf3365d09 100644 --- a/schema/connections.json +++ b/schema/connections.json @@ -75,6 +75,9 @@ }, "type": "array" }, + "ssh:passwordsecretname": { + "type": "string" + }, "ssh:batchmode": { "type": "boolean" }, From a4f27c76919e3f8c58c24e1a40bd1b6d95fad38f Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 28 Nov 2025 18:09:20 -0800 Subject: [PATCH 04/29] integrate secret store functionality into waveconfig --- .../app/view/waveconfig/secretscontent.tsx | 394 ++++++++++++++++++ .../app/view/waveconfig/waveconfig-model.ts | 210 +++++++++- frontend/app/view/waveconfig/waveconfig.tsx | 11 +- 3 files changed, 611 insertions(+), 4 deletions(-) create mode 100644 frontend/app/view/waveconfig/secretscontent.tsx diff --git a/frontend/app/view/waveconfig/secretscontent.tsx b/frontend/app/view/waveconfig/secretscontent.tsx new file mode 100644 index 0000000000..b090338de2 --- /dev/null +++ b/frontend/app/view/waveconfig/secretscontent.tsx @@ -0,0 +1,394 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { WaveConfigViewModel } from "@/app/view/waveconfig/waveconfig-model"; +import { cn } from "@/util/util"; +import { useAtomValue, useSetAtom } from "jotai"; +import { memo } from "react"; + +interface ErrorDisplayProps { + message: string; + variant?: "error" | "warning"; +} + +const ErrorDisplay = memo(({ message, variant = "error" }: ErrorDisplayProps) => { + const icon = variant === "error" ? "fa-circle-exclamation" : "fa-triangle-exclamation"; + const baseClasses = "flex items-center gap-2 p-4 border rounded-lg"; + const variantClasses = + variant === "error" + ? "bg-red-500/10 border-red-500/20 text-red-400" + : "bg-yellow-500/10 border-yellow-500/20 text-yellow-400"; + + return ( +
+ + {message} +
+ ); +}); +ErrorDisplay.displayName = "ErrorDisplay"; + +const LoadingSpinner = memo(({ message }: { message: string }) => { + return ( +
+ + {message} +
+ ); +}); +LoadingSpinner.displayName = "LoadingSpinner"; + +const EmptyState = memo(({ onAddSecret }: { onAddSecret: () => void }) => { + return ( +
+ +

No Secrets

+

Add a secret to get started

+ +
+ ); +}); +EmptyState.displayName = "EmptyState"; + +const CLIInfoBubble = memo(() => { + return ( +
+
+ +
CLI Access
+
+
+ wsh secret list +
+ wsh secret get [name] +
+ wsh secret set [name]=[value] +
+
+ ); +}); +CLIInfoBubble.displayName = "CLIInfoBubble"; + +interface SecretListViewProps { + secretNames: string[]; + onSelectSecret: (name: string) => void; + onAddSecret: () => void; +} + +const SecretListView = memo(({ secretNames, onSelectSecret, onAddSecret }: SecretListViewProps) => { + return ( +
+
+

Secrets

+ {secretNames.length} +
+
+ {secretNames.map((name) => ( +
onSelectSecret(name)} + > + + {name} + +
+ ))} +
+ + Add New Secret +
+
+ +
+ ); +}); +SecretListView.displayName = "SecretListView"; + +interface AddSecretFormProps { + newSecretName: string; + newSecretValue: string; + isLoading: boolean; + onNameChange: (name: string) => void; + onValueChange: (value: string) => void; + onCancel: () => void; + onSubmit: () => void; +} + +const AddSecretForm = memo( + ({ + newSecretName, + newSecretValue, + isLoading, + onNameChange, + onValueChange, + onCancel, + onSubmit, + }: AddSecretFormProps) => { + const secretNameRegex = /^[A-Za-z][A-Za-z0-9_]*$/; + const isNameInvalid = newSecretName !== "" && !secretNameRegex.test(newSecretName); + + return ( +
+

Add New Secret

+
+ + onNameChange(e.target.value)} + placeholder="MY_SECRET_NAME" + disabled={isLoading} + /> +
+ Must start with a letter and contain only letters, numbers, and underscores +
+
+
+ +