From a449cec33a00c2b14c1f834b7773374799a3cb31 Mon Sep 17 00:00:00 2001 From: Sylvie Crowe <107814465+oneirocosm@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:19:43 -0700 Subject: [PATCH] Sudo Config Gui (#603) * feat: add gui elements to configure ssh pw cache This adds a dropdown for on/off/notimeout, a number entry box for a timeout value, and a toggle for clearing when the computer sleeps. * fix: improve password timeout entry This makes the password timeout more consistent by using an inline settings element. It also creates the inline settings element to parse the input. * feat: turn sudo password caching on and off * feat: use configurable sudo timeout This makes it possible to control how long waveterm stores your sudo password. Note that if it changes, it immediately clears the cached passwords. * fix: clear existing sudo passwords if switched off When the sudo password store state is changed to "off", all existing passwords must immediately be cleared automatically. * feat: allow clearing sudo passwords on suspend This option makes it so the sudo passwords will be cleared when the computer falls asleep. It will never be used in the case where the password is set to never time out. * feat: allow notimeout to prevent sudo pw clear This option allows the sudo timeout to be ignored while it is selected. * feat: adjust current deadline based on user config This allows the deadline to update as changes to the config are happening. * fix: reject a sudopwtimeout of 0 on the backend * fix: use the default sudoPwTimeout for empty input * fix: specify the timeout length is minutes * fix: store sudopwtimeout in ms instead of minutes * fix: formatting the default sudo timeout By changing the order of operations, this no longer shows up as NaN if the default is used. * refactor: consolidate inlinesettingstextedit This removes the number variant and combines them into the same class with an option to switch between the two behaviors. * refactor: consolidate textfield and numberfield This removes the number variant of textfield. The textfield component can now act as a numberfield when the optional isNumber prop is true. --- src/app/appconst.ts | 2 + src/app/clientsettings/clientsettings.tsx | 66 +++++++++++++++++ src/app/common/elements/index.tsx | 1 - .../elements/inlinesettingstextedit.tsx | 7 ++ src/app/common/elements/numberfield.tsx | 39 ----------- src/app/common/elements/textfield.tsx | 7 +- src/app/common/modals/createremoteconn.tsx | 5 +- src/electron/emain.ts | 13 ++++ src/models/commandrunner.ts | 25 +++++++ src/models/model.ts | 16 +++++ src/types/custom.d.ts | 3 + wavesrv/cmd/main-server.go | 28 ++++++++ wavesrv/pkg/cmdrunner/cmdrunner.go | 70 ++++++++++++++++++- wavesrv/pkg/remote/remote.go | 41 +++++++++-- wavesrv/pkg/sstore/sstore.go | 17 +++-- 15 files changed, 286 insertions(+), 54 deletions(-) delete mode 100644 src/app/common/elements/numberfield.tsx diff --git a/src/app/appconst.ts b/src/app/appconst.ts index a395519d..0b517933 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 db0e73d0..fa243c05 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 0b170499..185cc274 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 7f2768e6..8968d606 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 1a40d684..00000000 --- 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 960611e8..6a37b0eb 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 55872df5..7b181d67 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 c98fac3e..f7e84d38 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 48fa3dc1..d0b585c4 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 348f88f6..361f729d 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 7a9db4ac..7789fce4 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 3709ed46..ef07a022 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 6ba5a56e..3e31f638 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 cd16910d..c6a00242 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 fb5c3d64..4aec42f6 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 {