diff --git a/cmd/apps/skychat/skychat.go b/cmd/apps/skychat/skychat.go index b35bfb317..8ed778a17 100644 --- a/cmd/apps/skychat/skychat.go +++ b/cmd/apps/skychat/skychat.go @@ -36,7 +36,7 @@ const ( port = routing.Port(1) ) -var addr = flag.String("addr", ":8001", "address to bind") +var addr = flag.String("addr", ":8001", "address to bind, put an * before the port if you want to be able to access outside localhost") var r = netutil.NewRetrier(nil, 50*time.Millisecond, netutil.DefaultMaxBackoff, 5, 2) var ( @@ -84,7 +84,20 @@ func main() { http.HandleFunc("/message", messageHandler(ctx)) http.HandleFunc("/sse", sseHandler) - fmt.Println("Serving HTTP on", *addr) + url := "" + address := *addr + if len(address) < 5 || (address[:1] != ":" && address[:2] != "*:") { + url = "127.0.0.1:8001" + } else if address[:1] == ":" { + url = "127.0.0.1" + address + } else if address[:2] == "*:" { + url = address[1:] + } else { + url = "127.0.0.1:8001" + } + + fmt.Println("Serving HTTP on", url) + if runtime.GOOS != "windows" { termCh := make(chan os.Signal, 1) signal.Notify(termCh, os.Interrupt) @@ -97,7 +110,7 @@ func main() { } setAppStatus(appCl, appserver.AppDetailedStatusRunning) srv := &http.Server{ //nolint gosec - Addr: *addr, + Addr: url, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, } diff --git a/pkg/visor/api.go b/pkg/visor/api.go index 5a1d88084..88d0cceef 100644 --- a/pkg/visor/api.go +++ b/pkg/visor/api.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync/atomic" "time" @@ -72,6 +73,7 @@ type API interface { SetAppPassword(appName, password string) error SetAppPK(appName string, pk cipher.PubKey) error SetAppSecure(appName string, isSecure bool) error + SetAppAddress(appName string, address string) error SetAppKillswitch(appName string, killswitch bool) error SetAppNetworkInterface(appName string, netifc string) error SetAppDNS(appName string, dnsaddr string) error @@ -769,6 +771,46 @@ func (v *Visor) SetAppSecure(appName string, isSecure bool) error { return nil } +// SetAppAddress implements API. +func (v *Visor) SetAppAddress(appName string, address string) error { + // check app launcher availability + if v.appL == nil { + return ErrAppLauncherNotAvailable + } + + if appName != visorconfig.SkychatName { + return fmt.Errorf("app %s is not allowed to set addr", appName) + } + + if len(address) < 5 || (address[:1] != ":" && address[:2] != "*:") { + return fmt.Errorf("invalid addr value: %s", address) + } + + forLocalhostOnly := address[:1] == ":" + prefix := 2 + if forLocalhostOnly { + prefix = 1 + } + + portNumber, err := strconv.Atoi(address[prefix:]) + if err != nil || portNumber < 1025 || portNumber > 65536 { + return fmt.Errorf("invalid port number: %s", strconv.Itoa(portNumber)) + } + + v.log.Infof("Setting %s addr to %v", appName, address) + + const ( + addrArg = "-addr" + ) + if err := v.conf.UpdateAppArg(v.appL, appName, addrArg, address); err != nil { + return err + } + + v.log.Infof("Updated %v addr state", appName) + + return nil +} + // SetAppPK implements API. func (v *Visor) SetAppPK(appName string, pk cipher.PubKey) error { allowedToChangePK := func(appName string) bool { diff --git a/pkg/visor/hypervisor.go b/pkg/visor/hypervisor.go index a797cb87c..cd45bccff 100644 --- a/pkg/visor/hypervisor.go +++ b/pkg/visor/hypervisor.go @@ -598,6 +598,7 @@ func (hv *Hypervisor) putApp() http.HandlerFunc { AutoStart *bool `json:"autostart,omitempty"` Killswitch *bool `json:"killswitch,omitempty"` Secure *bool `json:"secure,omitempty"` + Address *string `json:"Address,omitempty"` Status *int `json:"status,omitempty"` Passcode *string `json:"passcode,omitempty"` NetIfc *string `json:"netifc,omitempty"` @@ -608,7 +609,7 @@ func (hv *Hypervisor) putApp() http.HandlerFunc { shouldRestartApp := func(r req) bool { // we restart the app if one of these fields was changed - return r.Killswitch != nil || r.Secure != nil || r.Passcode != nil || + return r.Killswitch != nil || r.Secure != nil || r.Address != nil || r.Passcode != nil || r.PK != nil || r.NetIfc != nil || r.CustomSetting != nil } @@ -660,6 +661,13 @@ func (hv *Hypervisor) putApp() http.HandlerFunc { } } + if reqBody.Address != nil { + if err := ctx.API.SetAppAddress(ctx.App.Name, *reqBody.Address); err != nil { + httputil.WriteJSON(w, r, http.StatusInternalServerError, err) + return + } + } + if reqBody.NetIfc != nil { if err := ctx.API.SetAppNetworkInterface(ctx.App.Name, *reqBody.NetIfc); err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) diff --git a/pkg/visor/rpc.go b/pkg/visor/rpc.go index 2c008a16a..699a44cfe 100644 --- a/pkg/visor/rpc.go +++ b/pkg/visor/rpc.go @@ -391,6 +391,13 @@ func (r *RPC) SetAppSecure(in *SetAppBoolIn, _ *struct{}) (err error) { return r.visor.SetAppSecure(in.AppName, in.Val) } +// SetAppAddress sets addr flag for the app +func (r *RPC) SetAppAddress(in *SetAppStringIn, _ *struct{}) (err error) { + defer rpcutil.LogCall(r.log, "SetAppAddress", in)(nil, &err) + + return r.visor.SetAppAddress(in.AppName, in.Val) +} + // GetAppStats gets app runtime statistics. func (r *RPC) GetAppStats(appName *string, out *appserver.AppStats) (err error) { defer rpcutil.LogCall(r.log, "GetAppStats", appName)(out, &err) diff --git a/pkg/visor/rpc_client.go b/pkg/visor/rpc_client.go index 86c246c33..af9ea3dbd 100644 --- a/pkg/visor/rpc_client.go +++ b/pkg/visor/rpc_client.go @@ -267,6 +267,14 @@ func (rc *rpcClient) SetAppSecure(appName string, isSecure bool) error { }, &struct{}{}) } +// SetAppAddress implements API. +func (rc *rpcClient) SetAppAddress(appName string, address string) error { + return rc.Call("SetAppAddress", &SetAppStringIn{ + AppName: appName, + Val: address, + }, &struct{}{}) +} + // SetAppDNS implements API. func (rc *rpcClient) SetAppDNS(appName string, dnsAddr string) error { return rc.Call("SetAppDNS", &SetAppStringIn{ @@ -980,6 +988,21 @@ func (mc *mockRPCClient) SetAppSecure(appName string, isSecure bool) error { //n }) } +// SetAppAddress implements API. +func (mc *mockRPCClient) SetAppAddress(appName string, address string) error { //nolint:all + return mc.do(true, func() error { + const chatName = "skychat" + + for i := range mc.o.Apps { + if mc.o.Apps[i].Name == chatName { + return nil + } + } + + return fmt.Errorf("app of name '%s' does not exist", chatName) + }) +} + // SetAppDNS implements API. func (mc *mockRPCClient) SetAppDNS(string, string) error { return mc.do(true, func() error { diff --git a/static/skywire-manager-src/src/app/app.module.ts b/static/skywire-manager-src/src/app/app.module.ts index f03105cd2..21fab80c9 100644 --- a/static/skywire-manager-src/src/app/app.module.ts +++ b/static/skywire-manager-src/src/app/app.module.ts @@ -101,6 +101,7 @@ import { RewardsAddressComponent } from './components/pages/node/node-info/node- import { BulkRewardAddressChangerComponent } from './components/layout/bulk-reward-address-changer/bulk-reward-address-changer.component'; import { UserAppSettingsComponent } from './components/pages/node/apps/node-apps/user-app-settings/user-app-settings.component'; import { NodeLogsComponent } from './components/pages/node/actions/node-logs/node-logs.component'; +import { SkychatSettingsComponent } from './components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component'; import { TabSelectorComponent } from './components/layout/tab-selector/tab-selector.component'; const globalRippleConfig: RippleGlobalOptions = { @@ -179,6 +180,7 @@ const globalRippleConfig: RippleGlobalOptions = { BulkRewardAddressChangerComponent, UserAppSettingsComponent, NodeLogsComponent, + SkychatSettingsComponent, TabSelectorComponent, ], imports: [ diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps-list/node-apps-list.component.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps-list/node-apps-list.component.ts index d895368c7..38ae2dbed 100644 --- a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps-list/node-apps-list.component.ts +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps-list/node-apps-list.component.ts @@ -23,6 +23,7 @@ import { FilterProperties, FilterFieldTypes } from 'src/app/utils/filters'; import { SortingColumn, SortingModes, DataSorter } from 'src/app/utils/lists/data-sorter'; import { DataFilterer } from 'src/app/utils/lists/data-filterer'; import { UserAppSettingsComponent } from '../node-apps/user-app-settings/user-app-settings.component'; +import { SkychatSettingsComponent } from '../node-apps/skychat-settings/skychat-settings.component'; /** * Shows the list of applications of a node. It shows official or user apps, not both at the @@ -80,7 +81,7 @@ export class NodeAppsListComponent implements OnInit, OnDestroy { } // List with the names of all the apps which can not be configured directly on the manager. - appsWithoutConfig = new Set(['skychat']); + appsWithoutConfig = new Set(); // All apps the ode has. allApps: Application[]; @@ -248,14 +249,23 @@ export class NodeAppsListComponent implements OnInit, OnDestroy { */ getLink(app: Application): string { if (app.name.toLocaleLowerCase() === 'skychat' && this.nodeIp && app.status !== 0 && app.status !== 2) { - // Default port. + // Default port and ip. let port = '8001'; + let url = '127.0.0.1'; - // Try to get the port from the config array. + // Try to get the address and port from the config array. if (app.args) { for (let i = 0; i < app.args.length; i++) { if (app.args[i] === '-addr' && i + 1 < app.args.length) { - port = (app.args[i + 1] as string).trim(); + const addr = (app.args[i + 1] as string).trim(); + + const parts = addr.split(':'); + // If the app can be accessed outside localhost, use the remote ip. + if (parts[0] === '*') { + url = this.nodeIp; + } + + port = parts[1]; } } } @@ -264,7 +274,7 @@ export class NodeAppsListComponent implements OnInit, OnDestroy { port = ':' + port; } - return 'http://' + this.nodeIp + port; + return 'http://' + url + port; } else if (app.name.toLocaleLowerCase() === 'vpn-client' && this.nodePK) { return location.origin + '/#/vpn/' + this.nodePK + '/status'; } else if (!this.officialAppsList.has(app.name)) { @@ -574,12 +584,12 @@ export class NodeAppsListComponent implements OnInit, OnDestroy { * Shows the appropriate modal window for configuring the app. */ config(app: Application): void { - if (app.name === 'skysocks' || app.name === 'vpn-server') { + if (app.name === 'skychat') { + SkychatSettingsComponent.openDialog(this.dialog, app); + } else if (app.name === 'skysocks' || app.name === 'vpn-server') { SkysocksSettingsComponent.openDialog(this.dialog, app); } else if (app.name === 'skysocks-client' || app.name === 'vpn-client') { SkysocksClientSettingsComponent.openDialog(this.dialog, app); - } else if (app.name === 'skychat') { - this.snackbarService.showError('apps.error'); } else { UserAppSettingsComponent.openDialog(this.dialog, app); } diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.html b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.html new file mode 100644 index 000000000..5d53b0fcf --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.html @@ -0,0 +1,42 @@ + +
+ + +
+ + + {{ 'common.yes' | translate }} + {{ 'common.no' | translate }} + +
+
+ + +
+ + +
+ + {{ 'apps.skychat-settings.port-error' | translate }} + +
+
+ + + {{ 'apps.skychat-settings.save' | translate }} + +
diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.scss b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.ts b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.ts new file mode 100644 index 000000000..a2bb03924 --- /dev/null +++ b/static/skywire-manager-src/src/app/components/pages/node/apps/node-apps/skychat-settings/skychat-settings.component.ts @@ -0,0 +1,140 @@ +import { Component, OnInit, ViewChild, OnDestroy, Inject } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { MatDialogRef, MatDialog, MatDialogConfig, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; + +import { ButtonComponent } from '../../../../../layout/button/button.component'; +import { NodeComponent } from '../../../node.component'; +import { SnackbarService } from '../../../../../../services/snackbar.service'; +import { AppConfig } from 'src/app/app.config'; +import { processServiceError } from 'src/app/utils/errors'; +import { OperationError } from 'src/app/utils/operation-error'; +import { AppsService } from 'src/app/services/apps.service'; +import { Application } from 'src/app/app.datatypes'; +import GeneralUtils from 'src/app/utils/generalUtils'; + +/** + * Modal window used for configuring the Skychat app. + */ +@Component({ + selector: 'app-skychat-settings', + templateUrl: './skychat-settings.component.html', + styleUrls: ['./skychat-settings.component.scss'] +}) +export class SkychatSettingsComponent implements OnInit, OnDestroy { + @ViewChild('button') button: ButtonComponent; + form: UntypedFormGroup; + + private formSubscription: Subscription; + private operationSubscription: Subscription; + + /** + * Opens the modal window. Please use this function instead of opening the window "by hand". + */ + public static openDialog(dialog: MatDialog, app: Application): MatDialogRef { + const config = new MatDialogConfig(); + config.data = app; + config.autoFocus = false; + config.width = AppConfig.mediumModalWidth; + + return dialog.open(SkychatSettingsComponent, config); + } + + constructor( + @Inject(MAT_DIALOG_DATA) private data: Application, + private appsService: AppsService, + private formBuilder: UntypedFormBuilder, + public dialogRef: MatDialogRef, + private snackbarService: SnackbarService, + private dialog: MatDialog, + ) { } + + ngOnInit() { + this.form = this.formBuilder.group({ + localhostOnly: [true], + port: ['', Validators.compose([Validators.required, Validators.min(1025), Validators.max(65536)])], + }); + + this.formSubscription = this.form.get('localhostOnly').valueChanges.subscribe(value => { + // If "no" is selected ask for confirmation. + if (!value) { + this.form.get('localhostOnly').setValue(true); + const confirmationDialog = GeneralUtils.createConfirmationDialog(this.dialog, 'apps.skychat-settings.non-localhost-confirmation'); + + confirmationDialog.componentInstance.operationAccepted.subscribe(() => { + confirmationDialog.componentInstance.closeModal(); + + this.form.get('localhostOnly').setValue(false, { emitEvent: false }); + }); + } + }); + + // Get the current values saved on the visor, if returned by the API. + if (this.data.args && this.data.args.length > 0) { + for (let i = 0; i < this.data.args.length; i++) { + if (this.data.args[i] === '-addr' && i + 1 < this.data.args.length) { + const parts = (this.data.args[i + 1] as string).split(':'); + if (parts[0] === '*') { + this.form.get('localhostOnly').setValue(false); + } + + this.form.get('port').setValue(parts[1]); + } + } + } + } + + ngOnDestroy() { + if (this.formSubscription) { + this.formSubscription.unsubscribe(); + } + + if (this.operationSubscription) { + this.operationSubscription.unsubscribe(); + } + } + + /** + * If true, all the ways provided by default by the UI for closing the modal window are disabled. + */ + get disableDismiss(): boolean { + return this.button ? this.button.isLoading : false; + } + + /** + * Saves the settings. + */ + saveChanges() { + if (!this.form.valid || this.button.disabled) { + return; + } + + this.button.showLoading(); + + const data = {address: this.form.get('localhostOnly').value ? ':' : '*:'}; + data['address'] += this.form.get('port').value; + + this.operationSubscription = this.appsService.changeAppSettings( + // The node pk is obtained from the currently openned node page. + NodeComponent.getCurrentNodeKey(), + this.data.name, + data, + ).subscribe({ + next: this.onSuccess.bind(this), + error: this.onError.bind(this) + }); + } + + private onSuccess() { + NodeComponent.refreshCurrentDisplayedData(); + this.snackbarService.showDone('apps.skychat-settings.changes-made'); + this.dialogRef.close(); + } + + private onError(err: OperationError) { + this.button.showError(); + err = processServiceError(err); + + this.snackbarService.showError(err); + } +} diff --git a/static/skywire-manager-src/src/assets/i18n/en.json b/static/skywire-manager-src/src/assets/i18n/en.json index 79aa6c832..b01a27b8e 100644 --- a/static/skywire-manager-src/src/assets/i18n/en.json +++ b/static/skywire-manager-src/src/assets/i18n/en.json @@ -457,6 +457,15 @@ "empty-confirmation": "The settings list is empty. Do you really want to continue?", "changes-made": "The changes have been made." }, + "skychat-settings": { + "title": "Skychat Settings", + "localhost-only": "Allow access from the local machine only", + "port": "Port", + "save": "Save", + "changes-made": "The changes have been made.", + "port-error": "Must be a valid number between 1025 and 65536.", + "non-localhost-confirmation": "This will allow to use the app from anywhere on the internet. Are you sure you vant to continue?" + }, "vpn-socks-server-settings": { "socks-title": "Skysocks Settings", "vpn-title": "VPN-Server Settings", diff --git a/static/skywire-manager-src/src/assets/i18n/es.json b/static/skywire-manager-src/src/assets/i18n/es.json index 27dd44541..f369ed1fe 100644 --- a/static/skywire-manager-src/src/assets/i18n/es.json +++ b/static/skywire-manager-src/src/assets/i18n/es.json @@ -461,6 +461,15 @@ "empty-confirmation": "La lista de configuración está vacía. ¿Realmente desea continuar?", "changes-made": "Los cambios han sido realizados." }, + "skychat-settings": { + "title": "Configuración de Skychat", + "localhost-only": "Permitir acceso sólo desde la máquina local", + "port": "Puerto", + "save": "Guardar", + "changes-made": "Los cambios han sido realizados.", + "port-error": "Debe ser un número válido entre 1025 y 65536.", + "non-localhost-confirmation": "Esto permitirá usar la aplicación desde cualquier lugar en Internet. ¿Seguro que desea continuar?" + }, "vpn-socks-server-settings": { "socks-title": "Configuración de Skysocks", "vpn-title": "Configuración de VPN-Server", diff --git a/static/skywire-manager-src/src/assets/i18n/es_base.json b/static/skywire-manager-src/src/assets/i18n/es_base.json index 8c853eda9..7965323a2 100644 --- a/static/skywire-manager-src/src/assets/i18n/es_base.json +++ b/static/skywire-manager-src/src/assets/i18n/es_base.json @@ -461,6 +461,15 @@ "empty-confirmation": "The settings list is empty. Do you really want to continue?", "changes-made": "The changes have been made." }, + "skychat-settings": { + "title": "Skychat Settings", + "localhost-only": "Allow access from the local machine only", + "port": "Port", + "save": "Save", + "changes-made": "The changes have been made.", + "port-error": "Must be a valid number between 1025 and 65536.", + "non-localhost-confirmation": "This will allow to use the app from anywhere on the internet. Are you sure you vant to continue?" + }, "vpn-socks-server-settings": { "socks-title": "Skysocks Settings", "vpn-title": "VPN-Server Settings",