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.
This commit is contained in:
Sylvie Crowe 2024-04-25 18:19:43 -07:00 committed by GitHub
parent 5e3243564b
commit a449cec33a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 286 additions and 54 deletions

View File

@ -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;

View File

@ -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 (
<MainView className="clientsettings-view" title="Client Settings" onClose={this.handleClose}>
@ -375,6 +407,40 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Remember Sudo Password</div>
<div className="settings-input">
<Dropdown
className="hotkey-dropdown"
options={this.getSudoPwStoreOptions()}
defaultValue={curSudoPwStore}
onChange={this.handleChangeSudoPwStoreConfig}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Sudo Timeout (Minutes)</div>
<div className="settings-input">
<InlineSettingsTextEdit
placeholder=""
text={curSudoPwTimeout}
value={curSudoPwTimeout}
onChange={this.handleChangeSudoPwTimeoutConfig}
maxLength={6}
showIcon={true}
isNumber={true}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Clear Sudo Password on Sleep</div>
<div className="settings-input">
<Toggle
checked={curSudoPwClearOnSleep}
onChange={this.handleChangeSudoPwClearOnSleepConfig}
/>
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />
</div>
</MainView>

View File

@ -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";

View File

@ -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);
})();

View File

@ -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<HTMLInputElement>) {
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 };

View File

@ -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<TextFieldProps, TextFieldState> {
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
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 });

View File

@ -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<{}, {}> {
/>
</div>
<div className="port-section">
<NumberField
<TextField
label="Port"
placeholder="22"
value={this.tempPort.get()}
onChange={this.handleChangePort}
isNumber={true}
decoration={{
endDecoration: (
<InputDecoration>

View File

@ -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"));

View File

@ -467,6 +467,31 @@ class CommandRunner {
return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false);
}
setSudoPwStore(store: string): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
sudopwstore: store,
};
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
setSudoPwTimeout(timeout: string): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
sudopwtimeout: timeout,
};
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
setSudoPwClearOnSleep(clear: boolean): Promise<CommandRtnType> {
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",

View File

@ -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(() => {

View File

@ -598,6 +598,9 @@ declare global {
termfontfamily: string;
theme: NativeThemeSource;
termthemesettings: TermThemeSettingsType;
sudopwstore: "on" | "off" | "notimeout";
sudopwtimeoutms: number;
nosudopwclearonsleep: boolean;
};
type ConfirmFlagsType = {

View File

@ -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")

View File

@ -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))
}

View File

@ -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 {

View File

@ -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 {