diff --git a/src/app/appconst.ts b/src/app/appconst.ts index a395519d0..0b5179337 100644 --- a/src/app/appconst.ts +++ b/src/app/appconst.ts @@ -46,6 +46,8 @@ export const TabIcons = [ "heart", "file", ]; +export const DefaultSudoPwStore = "on"; +export const DefaultSudoPwTimeoutMs = 5 * 60 * 1000; export const MaxWebSocketSendSize = 64 * 1024 - 100; diff --git a/src/app/clientsettings/clientsettings.tsx b/src/app/clientsettings/clientsettings.tsx index db0e73d07..fa243c059 100644 --- a/src/app/clientsettings/clientsettings.tsx +++ b/src/app/clientsettings/clientsettings.tsx @@ -197,6 +197,35 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove GlobalModel.clientSettingsViewModel.closeView(); } + @boundMethod + getSudoPwStoreOptions(): DropdownItem[] { + const sudoCacheSources: DropdownItem[] = []; + sudoCacheSources.push({ label: "On", value: "on" }); + sudoCacheSources.push({ label: "Off", value: "off" }); + sudoCacheSources.push({ label: "On Without Timeout", value: "notimeout" }); + return sudoCacheSources; + } + + @boundMethod + handleChangeSudoPwStoreConfig(store: string) { + const prtn = GlobalCommandRunner.setSudoPwStore(store); + commandRtnHandler(prtn, this.errorMessage); + } + + @boundMethod + handleChangeSudoPwTimeoutConfig(timeout: string) { + if (Number(timeout) != 0) { + const prtn = GlobalCommandRunner.setSudoPwTimeout(timeout); + commandRtnHandler(prtn, this.errorMessage); + } + } + + @boundMethod + handleChangeSudoPwClearOnSleepConfig(clearOnSleep: boolean) { + const prtn = GlobalCommandRunner.setSudoPwClearOnSleep(clearOnSleep); + commandRtnHandler(prtn, this.errorMessage); + } + render() { const isHidden = GlobalModel.activeMainView.get() != "clientsettings"; if (isHidden) { @@ -217,6 +246,9 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove const curTheme = GlobalModel.getThemeSource(); const termThemes = getTermThemes(GlobalModel.termThemes.get(), "Wave Default"); const currTermTheme = GlobalModel.getTermThemeSettings()["root"] ?? termThemes[0].label; + const curSudoPwStore = GlobalModel.getSudoPwStore(); + const curSudoPwTimeout = String(GlobalModel.getSudoPwTimeout()); + const curSudoPwClearOnSleep = GlobalModel.getSudoPwClearOnSleep(); return ( @@ -375,6 +407,40 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove /> +
+
Remember Sudo Password
+
+ +
+
+
+
Sudo Timeout (Minutes)
+
+ +
+
+
+
Clear Sudo Password on Sleep
+
+ +
+
diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index 0b1704992..185cc2748 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -8,7 +8,6 @@ export { InputDecoration } from "./inputdecoration"; export { LinkButton } from "./linkbutton"; export { Markdown } from "./markdown"; export { Modal } from "./modal"; -export { NumberField } from "./numberfield"; export { PasswordField } from "./passwordfield"; export { ResizableSidebar } from "./resizablesidebar"; export { SettingsError } from "./settingserror"; diff --git a/src/app/common/elements/inlinesettingstextedit.tsx b/src/app/common/elements/inlinesettingstextedit.tsx index 7f2768e6b..8968d6066 100644 --- a/src/app/common/elements/inlinesettingstextedit.tsx +++ b/src/app/common/elements/inlinesettingstextedit.tsx @@ -22,6 +22,7 @@ class InlineSettingsTextEdit extends React.Component< maxLength: number; placeholder: string; showIcon?: boolean; + isNumber?: boolean; }, {} > { @@ -46,6 +47,12 @@ class InlineSettingsTextEdit extends React.Component< @boundMethod handleChangeText(e: any): void { + const isNumber = this.props.isNumber ?? false; + const value = e.target.value; + if (isNumber && value !== "" && !/^\d*$/.test(value)) { + return; + } + mobx.action(() => { this.tempText.set(e.target.value); })(); diff --git a/src/app/common/elements/numberfield.tsx b/src/app/common/elements/numberfield.tsx deleted file mode 100644 index 1a40d6847..000000000 --- a/src/app/common/elements/numberfield.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2023, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import * as React from "react"; -import { boundMethod } from "autobind-decorator"; - -import { TextField } from "./textfield"; - -class NumberField extends TextField { - @boundMethod - handleInputChange(e: React.ChangeEvent) { - const { required, onChange } = this.props; - const inputValue = e.target.value; - - // Allow only numeric input - if (inputValue === "" || /^\d*$/.test(inputValue)) { - // Update the internal state only if the component is not controlled. - if (this.props.value === undefined) { - const isError = required ? inputValue.trim() === "" : false; - - this.setState({ - internalValue: inputValue, - error: isError, - hasContent: Boolean(inputValue), - }); - } - - onChange && onChange(inputValue); - } - } - - render() { - // Use the render method from TextField but add the onKeyDown handler - const renderedTextField = super.render(); - return React.cloneElement(renderedTextField); - } -} - -export { NumberField }; diff --git a/src/app/common/elements/textfield.tsx b/src/app/common/elements/textfield.tsx index 960611e84..6a37b0eb6 100644 --- a/src/app/common/elements/textfield.tsx +++ b/src/app/common/elements/textfield.tsx @@ -27,6 +27,7 @@ interface TextFieldProps { maxLength?: number; autoFocus?: boolean; disabled?: boolean; + isNumber?: boolean; } interface TextFieldState { @@ -108,9 +109,13 @@ class TextField extends React.Component { @boundMethod handleInputChange(e: React.ChangeEvent) { - const { required, onChange } = this.props; + const { required, onChange, isNumber } = this.props; const inputValue = e.target.value; + if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) { + return; + } + // Check if value is empty and the field is required if (required && !inputValue) { this.setState({ error: true, hasContent: false }); diff --git a/src/app/common/modals/createremoteconn.tsx b/src/app/common/modals/createremoteconn.tsx index 55872df56..7b181d677 100644 --- a/src/app/common/modals/createremoteconn.tsx +++ b/src/app/common/modals/createremoteconn.tsx @@ -7,7 +7,7 @@ import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { If } from "tsx-control-statements/components"; import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models"; -import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements"; +import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements"; import * as util from "@/util/util"; import "./createremoteconn.less"; @@ -236,11 +236,12 @@ class CreateRemoteConnModal extends React.Component<{}, {}> { />
- diff --git a/src/electron/emain.ts b/src/electron/emain.ts index c98fac3eb..f7e84d38f 100644 --- a/src/electron/emain.ts +++ b/src/electron/emain.ts @@ -419,6 +419,17 @@ function mainResizeHandler(_: any, win: Electron.BrowserWindow) { }); } +function mainPowerHandler(status: string) { + const url = new URL(getBaseHostPort() + "/api/power-monitor"); + const fetchHeaders = getFetchHeaders(); + const body = { status: status }; + fetch(url, { method: "post", body: JSON.stringify(body), headers: fetchHeaders }) + .then((resp) => handleJsonFetchResponse(url, resp)) + .catch((err) => { + console.log("error setting power monitor state", err); + }); +} + function calcBounds(clientData: ClientDataType): Electron.Rectangle { const primaryDisplay = electron.screen.getPrimaryDisplay(); const pdBounds = primaryDisplay.bounds; @@ -946,3 +957,5 @@ function configureAutoUpdater(enabled: boolean) { } }); })(); + +electron.powerMonitor.on("suspend", () => mainPowerHandler("suspend")); diff --git a/src/models/commandrunner.ts b/src/models/commandrunner.ts index 48fa3dc1d..d0b585c4a 100644 --- a/src/models/commandrunner.ts +++ b/src/models/commandrunner.ts @@ -467,6 +467,31 @@ class CommandRunner { return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false); } + setSudoPwStore(store: string): Promise { + let kwargs = { + nohist: "1", + sudopwstore: store, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + setSudoPwTimeout(timeout: string): Promise { + let kwargs = { + nohist: "1", + sudopwtimeout: timeout, + }; + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + + setSudoPwClearOnSleep(clear: boolean): Promise { + let kwargs = { + nohist: "1", + sudopwclearonsleep: String(clear), + }; + console.log(kwargs); + return GlobalModel.submitCommand("client", "set", null, kwargs, false); + } + editBookmark(bookmarkId: string, desc: string, cmdstr: string) { let kwargs = { nohist: "1", diff --git a/src/models/model.ts b/src/models/model.ts index 348f88f6b..361f729d7 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -455,6 +455,22 @@ class Model { return this.termFontSize.get(); } + getSudoPwStore(): string { + let cdata = this.clientData.get(); + return cdata?.feopts?.sudopwstore ?? appconst.DefaultSudoPwStore; + } + + getSudoPwTimeout(): number { + let cdata = this.clientData.get(); + const sudoPwTimeoutMs = cdata?.feopts?.sudopwtimeoutms ?? appconst.DefaultSudoPwTimeoutMs; + return sudoPwTimeoutMs / 1000 / 60; + } + + getSudoPwClearOnSleep(): boolean { + let cdata = this.clientData.get(); + return !cdata?.feopts?.nosudopwclearonsleep; + } + updateTermFontSizeVars() { let lhe = this.recomputeLineHeightEnv(); mobx.action(() => { diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index 7a9db4acb..7789fce4c 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -598,6 +598,9 @@ declare global { termfontfamily: string; theme: NativeThemeSource; termthemesettings: TermThemeSettingsType; + sudopwstore: "on" | "off" | "notimeout"; + sudopwtimeoutms: number; + nosudopwclearonsleep: boolean; }; type ConfirmFlagsType = { diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index 3709ed46e..ef07a022e 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -209,6 +209,33 @@ func HandleSetWinSize(w http.ResponseWriter, r *http.Request) { WriteJsonSuccess(w, true) } +func HandlePowerMonitor(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var body sstore.PowerMonitorEventType + err := decoder.Decode(&body) + if err != nil { + WriteJsonError(w, fmt.Errorf(ErrorDecodingJson, err)) + return + } + cdata, err := sstore.EnsureClientData(r.Context()) + if err != nil { + WriteJsonError(w, err) + return + } + switch body.Status { + case "suspend": + if !cdata.FeOpts.NoSudoPwClearOnSleep && cdata.FeOpts.SudoPwStore != "notimeout" { + for _, proc := range remote.GetRemoteMap() { + proc.ClearCachedSudoPw() + } + } + WriteJsonSuccess(w, true) + default: + WriteJsonError(w, fmt.Errorf("unknown status: %s", body.Status)) + return + } +} + // params: fg, active, open func HandleLogActiveState(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) @@ -1149,6 +1176,7 @@ func main() { gr.HandleFunc(bufferedpipe.BufferedPipeGetterUrl, AuthKeyWrapAllowHmac(bufferedpipe.HandleGetBufferedPipeOutput)) gr.HandleFunc("/api/get-client-data", AuthKeyWrap(HandleGetClientData)) gr.HandleFunc("/api/set-winsize", AuthKeyWrap(HandleSetWinSize)) + gr.HandleFunc("/api/power-monitor", AuthKeyWrap(HandlePowerMonitor)) gr.HandleFunc("/api/log-active-state", AuthKeyWrap(HandleLogActiveState)) gr.HandleFunc("/api/read-file", AuthKeyWrapAllowHmac(HandleReadFile)) gr.HandleFunc("/api/write-file", AuthKeyWrap(HandleWriteFile)).Methods("POST") diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 6ba5a56ee..3e31f6383 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -642,10 +642,17 @@ func RunCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.Up } runPacket.Command = strings.TrimSpace(cmdStr) runPacket.ReturnState = resolveBool(pk.Kwargs["rtnstate"], isRtnStateCmd) + + clientData, err := sstore.EnsureClientData(ctx) + if err != nil { + return nil, fmt.Errorf("cannot retrieve client data: %v", err) + } + feOpts := clientData.FeOpts + if sudoArg, ok := pk.Kwargs[KwArgSudo]; ok { - runPacket.IsSudo = resolveBool(sudoArg, false) + runPacket.IsSudo = resolveBool(sudoArg, false) && feOpts.SudoPwStore != "off" } else { - runPacket.IsSudo = IsSudoCommand(cmdStr) + runPacket.IsSudo = IsSudoCommand(cmdStr) && feOpts.SudoPwStore != "off" } rcOpts := remote.RunCommandOpts{ SessionId: ids.SessionId, @@ -5810,6 +5817,13 @@ func CheckOptionAlias(kwargs map[string]string, aliases ...string) (string, bool return "", false } +func validateSudoPwStore(config string) error { + if utilfn.ContainsStr([]string{"on", "off", "notimeout"}, config) { + return nil + } + return fmt.Errorf("%s is not a config option", config) +} + func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { clientData, err := sstore.EnsureClientData(ctx) if err != nil { @@ -5996,6 +6010,58 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc } varsUpdated = append(varsUpdated, "webgl") } + if sudoPwStoreStr, found := pk.Kwargs["sudopwstore"]; found { + err := validateSudoPwStore(sudoPwStoreStr) + if err != nil { + return nil, fmt.Errorf("invalid sudo pw store, must be \"on\", \"off\", \"notimeout\": %v", err) + } + feOpts := clientData.FeOpts + feOpts.SudoPwStore = strings.ToLower(sudoPwStoreStr) + err = sstore.UpdateClientFeOpts(ctx, feOpts) + if err != nil { + return nil, fmt.Errorf("error updating client feopts: %v", err) + } + // clear all sudo pw if turning off + if feOpts.SudoPwStore == "off" { + for _, proc := range remote.GetRemoteMap() { + proc.ClearCachedSudoPw() + } + } + varsUpdated = append(varsUpdated, "sudopwstore") + } + if sudoPwTimeoutStr, found := pk.Kwargs["sudopwtimeout"]; found { + oldPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60 // ms to minutes + if oldPwTimeout == 0 { + oldPwTimeout = sstore.DefaultSudoTimeout + } + newSudoPwTimeout, err := resolveNonNegInt(sudoPwTimeoutStr, sstore.DefaultSudoTimeout) + if err != nil { + return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0: %v", err) + } + if newSudoPwTimeout == 0 { + return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0") + } + feOpts := clientData.FeOpts + feOpts.SudoPwTimeoutMs = newSudoPwTimeout * 60 * 1000 // minutes to ms + err = sstore.UpdateClientFeOpts(ctx, feOpts) + if err != nil { + return nil, fmt.Errorf("error updating client feopts: %v", err) + } + for _, proc := range remote.GetRemoteMap() { + proc.ChangeSudoTimeout(int64(newSudoPwTimeout - oldPwTimeout)) + } + varsUpdated = append(varsUpdated, "sudopwtimeout") + } + if sudoPwClearOnSleepStr, found := pk.Kwargs["sudopwclearonsleep"]; found { + newSudoPwClearOnSleep := resolveBool(sudoPwClearOnSleepStr, true) + feOpts := clientData.FeOpts + feOpts.NoSudoPwClearOnSleep = !newSudoPwClearOnSleep + err = sstore.UpdateClientFeOpts(ctx, feOpts) + if err != nil { + return nil, fmt.Errorf("error updating client feopts: %v", err) + } + varsUpdated = append(varsUpdated, "sudopwclearonsleep") + } if len(varsUpdated) == 0 { return nil, fmt.Errorf("/client:set requires a value to set: %s", formatStrs([]string{"termfontsize", "termfontfamily", "openaiapitoken", "openaimodel", "openaibaseurl", "openaimaxtokens", "openaimaxchoices", "openaitimeout", "webgl"}, "or", false)) } diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index cd16910d0..c6a002424 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -2626,11 +2626,21 @@ func sendScreenUpdates(screens []*sstore.ScreenType) { } } -func (msh *MShellProc) startSudoPwClearChecker() { +func (msh *MShellProc) startSudoPwClearChecker(clientData *sstore.ClientData) { + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + sudoPwStore := clientData.FeOpts.SudoPwStore for { + clientData, err := sstore.EnsureClientData(ctx) + if err != nil { + log.Printf("*error: cannot obtain client data in sudo pw loop. using fallback: %v", err) + } else { + sudoPwStore = clientData.FeOpts.SudoPwStore + } + shouldExit := false msh.WithLock(func() { - if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline { + if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline && sudoPwStore != "notimeout" { msh.sudoPw = nil msh.sudoClearDeadline = 0 } @@ -2668,13 +2678,25 @@ func (msh *MShellProc) sendSudoPassword(sudoPk *packet.SudoRequestPacketType) er } rawSecret = []byte(guiResponse.Text) } - //new + + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + clientData, err := sstore.EnsureClientData(ctx) + if err != nil { + return fmt.Errorf("*error: cannot obtain client data: %v", err) + } + sudoPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60 + if sudoPwTimeout == 0 { + // 0 maps to default + sudoPwTimeout = sstore.DefaultSudoTimeout + } + pwTimeoutDur := time.Duration(sudoPwTimeout) * time.Minute msh.WithLock(func() { msh.sudoPw = rawSecret if msh.sudoClearDeadline == 0 { - go msh.startSudoPwClearChecker() + go msh.startSudoPwClearChecker(clientData) } - msh.sudoClearDeadline = time.Now().Add(SudoTimeoutTime).Unix() + msh.sudoClearDeadline = time.Now().Add(pwTimeoutDur).Unix() }) srvPrivKey, err := ecdh.P256().GenerateKey(rand.Reader) @@ -2767,6 +2789,15 @@ func (msh *MShellProc) ClearCachedSudoPw() { }) } +func (msh *MShellProc) ChangeSudoTimeout(deltaTime int64) { + msh.WithLock(func() { + if msh.sudoClearDeadline != 0 { + updated := msh.sudoClearDeadline + deltaTime*60 + msh.sudoClearDeadline = max(0, updated) + } + }) +} + func (msh *MShellProc) ProcessPackets() { defer msh.WithLock(func() { if msh.Status == StatusConnected { diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index fb5c3d641..4aec42f68 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -43,6 +43,7 @@ const DBWALFileNameBackup = "backup.waveterm.db-wal" const MaxWebShareLineCount = 50 const MaxWebShareScreenCount = 3 const MaxLineStateSize = 4 * 1024 // 4k for now, can raise if needed +const DefaultSudoTimeout = 5 const DefaultSessionName = "default" const LocalRemoteAlias = "local" @@ -232,6 +233,10 @@ type ClientWinSizeType struct { FullScreen bool `json:"fullscreen,omitempty"` } +type PowerMonitorEventType struct { + Status string `json:"status"` +} + type SidebarValueType struct { Collapsed bool `json:"collapsed"` Width int `json:"width"` @@ -250,10 +255,14 @@ type ClientOptsType struct { } type FeOptsType struct { - TermFontSize int `json:"termfontsize,omitempty"` - TermFontFamily string `json:"termfontfamily,omitempty"` - Theme string `json:"theme,omitempty"` - TermThemeSettings map[string]string `json:"termthemesettings"` + TermFontSize int `json:"termfontsize,omitempty"` + TermFontFamily string `json:"termfontfamily,omitempty"` + Theme string `json:"theme,omitempty"` + TermThemeSettings map[string]string `json:"termthemesettings"` + SudoPwStore string `json:"sudopwstore,omitempty"` + SudoPwTimeoutMs int `json:"sudopwtimeoutms,omitempty"` + SudoPwTimeout int `json:"sudopwtimeout,omitempty"` + NoSudoPwClearOnSleep bool `json:"nosudopwclearonsleep,omitempty"` } type ReleaseInfoType struct {