@@ -202,8 +202,46 @@
Cleanup Network History
+
+
+
+
+
+
+ Paused: {{ pauseInfo }}
+
+
+ Auto-resume at: {{ pauseInfoTillTime }}
+
+
+
+
+
+ Pause SPN for 3 minutes
+ Pause SPN for 15 minutes
+ Pause SPN for 1 hour
+
+ Pause for 3 minutes
+ Pause for 15 minutes
+ Pause for 1 hour
+
+ Resume
+
+
-
diff --git a/desktop/angular/src/app/layout/navigation/navigation.ts b/desktop/angular/src/app/layout/navigation/navigation.ts
index 4752301f..f5650456 100644
--- a/desktop/angular/src/app/layout/navigation/navigation.ts
+++ b/desktop/angular/src/app/layout/navigation/navigation.ts
@@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, In
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api';
import { tap } from 'rxjs/operators';
import { AppComponent } from 'src/app/app.component';
-import { NotificationType, NotificationsService, StatusService, VersionStatus } from 'src/app/services';
+import { NotificationType, NotificationsService, StatusService, VersionStatus, GetModuleState, ControlPauseStateData } from 'src/app/services';
import { ActionIndicatorService } from 'src/app/shared/action-indicator';
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
import { ExitService } from 'src/app/shared/exit-screen';
@@ -36,6 +36,24 @@ export class NavigationComponent implements OnInit {
/** The color to use for the notifcation-available hint (dot) */
notificationColor: string = 'text-green-300';
+ pauseState: ControlPauseStateData | null = null;
+ get isPaused(): boolean { return this.pauseState?.Interception===true || this.pauseState?.SPN===true; }
+ get isPausedInterception(): boolean { return this.pauseState?.Interception===true; }
+ get pauseInfo(): string {
+ if (this.pauseState?.Interception===true && this.pauseState?.SPN===true)
+ return 'Portmaster and SPN';
+ else if (this.pauseState?.Interception===true)
+ return 'Portmaster';
+ else if (this.pauseState?.SPN===true)
+ return 'SPN';
+ return '';
+ }
+ get pauseInfoTillTime(): string {
+ if (this.isPaused && this.pauseState?.TillTime)
+ return new Date(this.pauseState.TillTime).toLocaleTimeString(undefined, { hour12: false });
+ return '';
+ }
+
/** Whether or not we have new, unseen prompts */
hasNewPrompts = false;
@@ -93,6 +111,10 @@ export class NavigationComponent implements OnInit {
this.cdr.markForCheck();
});
+ this.statusService.status$.subscribe(status => {
+ this.pauseState = GetModuleState(status, 'Control', 'control:paused')?.Data || null;
+ });
+
this.configService.watch('filter/defaultAction')
.subscribe(defaultAction => {
this.globalPromptingEnabled = defaultAction === 'ask';
@@ -251,6 +273,43 @@ export class NavigationComponent implements OnInit {
))
}
+ pause(event: Event, duration: number) {
+ // prevent default and stop-propagation to avoid
+ // expanding the accordion body.
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.portapi.pause(duration, false)
+ .subscribe(this.actionIndicator.httpObserver(
+ 'Pausing ...',
+ 'Failed to Pause',
+ ))
+ }
+ pauseSPN(event: Event, duration: number) {
+ // prevent default and stop-propagation to avoid
+ // expanding the accordion body.
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.portapi.pause(duration, true)
+ .subscribe(this.actionIndicator.httpObserver(
+ 'Pausing SPN...',
+ 'Failed to Pause SPN',
+ ))
+ }
+ resume(event: Event) {
+ // prevent default and stop-propagation to avoid
+ // expanding the accordion body.
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.portapi.resume()
+ .subscribe(this.actionIndicator.httpObserver(
+ 'Resuming ...',
+ 'Failed to Resume',
+ ))
+ }
+
/**
* @private
* Opens the data-directory of the portmaster installation.
diff --git a/desktop/angular/src/app/services/status.types.ts b/desktop/angular/src/app/services/status.types.ts
index 38bfa6fc..5dc96fc7 100644
--- a/desktop/angular/src/app/services/status.types.ts
+++ b/desktop/angular/src/app/services/status.types.ts
@@ -27,7 +27,7 @@ export function getOnlineStatusString(stat: OnlineStatus): string {
export interface CoreStatus extends Record {
OnlineStatus: OnlineStatus;
CaptivePortal: CaptivePortal;
- // Modules: []ModuleState; // TODO: Do we need all modules?
+ Modules: StateUpdate[]; // TODO: Do we need all modules?
WorstState: {
Module: string,
ID: string,
@@ -39,6 +39,20 @@ export interface CoreStatus extends Record {
}
}
+export interface StateUpdate {
+ Module: string;
+ States: State[];
+}
+
+export interface State {
+ ID: string; // Program-unique identifier
+ Name: string; // State name (may serve as notification title)
+ Message?: string; // Detailed message about the state
+ Type?: ModuleStateType; // State type
+ Time?: Date; // Creation time
+ Data?: any; // Additional data for processing
+}
+
export enum ModuleStateType {
Undefined = "",
Hint = "hint",
@@ -110,3 +124,55 @@ export interface VersionStatus extends Record {
[key: string]: Resource
}
}
+
+function getModuleStates(status: CoreStatus, moduleID: string): State[] {
+ const module = status.Modules?.find(m => m.Module === moduleID);
+ return module?.States || [];
+}
+
+/**
+ * Retrieves a specific state from a module within the CoreStatus.
+ * @param status The CoreStatus object containing module states.
+ * @param moduleID The identifier of the module to search within.
+ * @param stateID The identifier of the state to retrieve.
+ * @returns The State object if found; otherwise, null.
+ * @example
+ * ```typescript
+ * const state = GetModuleState(status, 'Control', 'control:paused');
+ * if (state) {
+ * console.log(`State found: ${state.Name}`);
+ * } else {
+ * console.log('State not found');
+ * }
+ * ```
+ */
+export function GetModuleState(status: CoreStatus, moduleID: string, stateID: string): State | null {
+ const states = getModuleStates(status, moduleID);
+ for (const state of states) {
+ if (state.ID === stateID) {
+ return state;
+ }
+ }
+ return null;
+}
+
+/**
+ * Data structure for the 'control:paused' state from the 'Control' module.
+ *
+ * This interface defines the expected structure of the Data field when Portmaster
+ * or its components are temporarily paused by the user.
+ *
+ * @example
+ * ```typescript
+ * const pausedState = GetModuleState(status, 'Control', 'control:paused');
+ * if (pausedState?.Data) {
+ * const pauseData = pausedState.Data as ControlPauseStateData;
+ * console.log(`SPN paused: ${pauseData.SPN}`);
+ * }
+ * ```
+ */
+export interface ControlPauseStateData {
+ Interception: boolean; // Whether Portmaster interception is paused
+ SPN: boolean; // Whether SPN is paused
+ TillTime: string; // When the pause will end (JSON date as string, has to be converted to Date)
+}
diff --git a/desktop/angular/src/app/shared/security-lock/security-lock.ts b/desktop/angular/src/app/shared/security-lock/security-lock.ts
index 1485cc11..a18e0f47 100644
--- a/desktop/angular/src/app/shared/security-lock/security-lock.ts
+++ b/desktop/angular/src/app/shared/security-lock/security-lock.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from "@angular/core";
import { SecurityLevel } from "@safing/portmaster-api";
import { combineLatest } from "rxjs";
-import { StatusService, ModuleStateType } from "src/app/services";
+import { StatusService, ModuleStateType, GetModuleState, ControlPauseStateData } from "src/app/services";
import { fadeInAnimation, fadeOutAnimation } from "../animations";
interface SecurityOption {
@@ -62,6 +62,17 @@ export class SecurityLockComponent implements OnInit {
break;
}
+ // Checking for Control:Paused state
+ const pausedState = GetModuleState(status, 'Control', 'control:paused');
+ if (pausedState?.Data) {
+ const pauseData = pausedState.Data as ControlPauseStateData;
+ if (pauseData.Interception === true) {
+ this.lockLevel.displayText = 'Insecure: PAUSED';
+ } else if (pauseData.SPN === true) {
+ this.lockLevel.displayText = 'Secure: SPN Paused';
+ }
+ }
+
this.cdr.markForCheck();
});
}
diff --git a/desktop/angular/src/i18n/helptexts.yaml b/desktop/angular/src/i18n/helptexts.yaml
index e00b6023..5a1e62de 100644
--- a/desktop/angular/src/i18n/helptexts.yaml
+++ b/desktop/angular/src/i18n/helptexts.yaml
@@ -99,6 +99,14 @@ navTools:
title: Version and Tools
content: |
View the Portmaster's version and use special actions and tools.
+ nextKey: navPause
+
+navPause:
+ title: Pause and Resume
+ content: |
+ Temporarily disable Portmaster's protection and network monitoring.
+
+ Choose to pause SPN only or disable all protection completely.
nextKey: navPower
navPower:
diff --git a/service/control/api.go b/service/control/api.go
index a15ddb70..cf656419 100644
--- a/service/control/api.go
+++ b/service/control/api.go
@@ -1,11 +1,19 @@
package control
import (
+ "encoding/json"
+ "fmt"
"net/http"
+ "time"
"github.com/safing/portmaster/base/api"
)
+const (
+ APIEndpointPause = "control/pause"
+ APIEndpointResume = "control/resume"
+)
+
type pauseRequestParams struct {
Duration int `json:"duration"` // Duration in seconds
OnlySPN bool `json:"onlySPN"` // Whether to pause only the SPN service
@@ -14,7 +22,7 @@ type pauseRequestParams struct {
func (c *Control) registerAPIEndpoints() error {
if err := api.RegisterEndpoint(api.Endpoint{
- Path: "control/pause",
+ Path: APIEndpointPause,
Write: api.PermitAdmin,
ActionFunc: c.handlePause,
Name: "Pause Portmaster",
@@ -36,7 +44,7 @@ func (c *Control) registerAPIEndpoints() error {
}
if err := api.RegisterEndpoint(api.Endpoint{
- Path: "control/resume",
+ Path: APIEndpointResume,
Write: api.PermitAdmin,
ActionFunc: c.handleResume,
Name: "Resume Portmaster",
@@ -47,3 +55,32 @@ func (c *Control) registerAPIEndpoints() error {
return nil
}
+
+func (c *Control) handlePause(r *api.Request) (msg string, err error) {
+ params := pauseRequestParams{}
+ if r.InputData != nil {
+ if err := json.Unmarshal(r.InputData, ¶ms); err != nil {
+ return "Bad Request: invalid input data", err
+ }
+ }
+
+ if params.OnlySPN {
+ c.mgr.Info(fmt.Sprintf("Received SPN Pause(%v) action request ", params.Duration))
+ } else {
+ c.mgr.Info(fmt.Sprintf("Received Pause(%v) action request ", params.Duration))
+ }
+
+ if err := c.pause(time.Duration(params.Duration)*time.Second, params.OnlySPN); err != nil {
+ return "Failed to pause", err
+ }
+ return "Pause initiated", nil
+}
+
+func (c *Control) handleResume(_ *api.Request) (msg string, err error) {
+ c.mgr.Info("Received Resume action request")
+
+ if err := c.resume(); err != nil {
+ return "Failed to resume", err
+ }
+ return "Resume initiated", nil
+}
diff --git a/service/control/module.go b/service/control/module.go
index ce22d4a9..30fa8130 100644
--- a/service/control/module.go
+++ b/service/control/module.go
@@ -7,23 +7,27 @@ import (
"time"
"github.com/safing/portmaster/base/config"
+ "github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service/compat"
"github.com/safing/portmaster/service/firewall/interception"
"github.com/safing/portmaster/service/mgr"
)
-var logPrefix = "control: "
+type PauseInfo struct {
+ Interception bool // Whether Portmaster interception is paused
+ SPN bool // Whether SPN is paused
+ TillTime time.Time // When the pause will end
+}
type Control struct {
mgr *mgr.Manager
instance instance
+ states *mgr.StateMgr
- locker sync.Mutex
- pauseWorker *mgr.WorkerMgr
- isPaused bool
- isPausedSPN bool
- pauseStartTime time.Time
- pauseDuration time.Duration
+ locker sync.Mutex
+ resumeWorker *mgr.WorkerMgr
+ pauseNotification *notifications.Notification
+ pauseInfo PauseInfo
}
type instance interface {
@@ -31,6 +35,7 @@ type instance interface {
Interception() *interception.Interception
Compat() *compat.Compat
SPNGroup() *mgr.ExtendedGroup
+ IsShuttingDown() bool
}
var (
@@ -42,10 +47,11 @@ func New(instance instance) (*Control, error) {
return nil, fmt.Errorf("control: New failed: instance already created")
}
- mgr := mgr.New("Control")
+ m := mgr.New("Control")
module := &Control{
- mgr: mgr,
+ mgr: m,
instance: instance,
+ states: mgr.NewStateMgr(m),
}
if err := module.prep(); err != nil {
return nil, err
@@ -57,6 +63,10 @@ func (c *Control) Manager() *mgr.Manager {
return c.mgr
}
+func (u *Control) States() *mgr.StateMgr {
+ return u.states
+}
+
func (c *Control) Start() error {
return nil
}
diff --git a/service/control/pause.go b/service/control/pause.go
index 784f6be4..c872a71f 100644
--- a/service/control/pause.go
+++ b/service/control/pause.go
@@ -1,58 +1,43 @@
package control
import (
- "encoding/json"
"errors"
"fmt"
"time"
- "github.com/safing/portmaster/base/api"
"github.com/safing/portmaster/base/config"
+ "github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service/mgr"
)
-func (c *Control) handlePause(r *api.Request) (msg string, err error) {
- params := pauseRequestParams{}
- if r.InputData != nil {
- if err := json.Unmarshal(r.InputData, ¶ms); err != nil {
- return "Bad Request: invalid input data", err
- }
+func (c *Control) pause(duration time.Duration, onlySPN bool) (retErr error) {
+ if c.instance.IsShuttingDown() {
+ c.mgr.Debug("Cannot pause: system is shutting down")
+ return nil
}
- if params.OnlySPN {
- c.mgr.Info(fmt.Sprintf("Received SPN PAUSE(%v) action request ", params.Duration))
- } else {
- c.mgr.Info(fmt.Sprintf("Received PAUSE(%v) action request ", params.Duration))
- }
-
- if err := c.impl_pause(time.Duration(params.Duration)*time.Second, params.OnlySPN); err != nil {
- return "Failed to pause", err
- }
- return "Pause initiated", nil
-}
-
-func (c *Control) handleResume(_ *api.Request) (msg string, err error) {
- c.mgr.Info("Received RESUME action request")
- if err := c.impl_resume(); err != nil {
- return "Failed to resume", err
- }
- return "Resume initiated", nil
-}
-
-func (c *Control) impl_pause(duration time.Duration, onlySPN bool) (retErr error) {
c.locker.Lock()
defer c.locker.Unlock()
+ defer func() {
+ // update states after pause attempt
+ c.updateStatesAndNotify()
+ // log error if pause failed
+ if retErr != nil {
+ c.mgr.Error("Failed to pause: " + retErr.Error())
+ }
+ }()
+
if duration <= 0 {
- return errors.New(logPrefix + "invalid pause duration")
+ return errors.New("invalid pause duration")
}
if onlySPN {
- if c.isPaused {
- return errors.New(logPrefix + "cannot pause SPN separately when core is paused")
+ if c.pauseInfo.Interception {
+ return errors.New("cannot pause SPN separately when core is paused")
}
- if !c.isPausedSPN && !c.instance.SPNGroup().Ready() {
- return errors.New(logPrefix + "cannot pause SPN when it is not running")
+ if !c.pauseInfo.SPN && !c.instance.SPNGroup().Ready() {
+ return errors.New("cannot pause SPN when it is not running")
}
}
@@ -64,99 +49,83 @@ func (c *Control) impl_pause(duration time.Duration, onlySPN bool) (retErr error
}
}()
- if !c.isPausedSPN {
+ if !c.pauseInfo.SPN {
if c.instance.SPNGroup().Ready() {
-
- // TODO: the 'pause' state must not make permanent config changes.
- // Consider possibility to not store permanent config changes.
- // E.g. SPN enabled -> pause SPN -> restart PC/Portmaster -> SPN should be enabled again.
enabled := config.GetAsBool("spn/enable", false)
if enabled() {
+ // TODO: the 'pause' state must not make permanent config changes.
+ // Consider possibility to not store permanent config changes.
+ // E.g. SPN enabled -> pause SPN -> restart PC/Portmaster -> SPN should be enabled again.
config.SetConfigOption("spn/enable", false)
+ c.mgr.Info("SPN paused")
}
-
- // Alternatively, we could directly stop SPN here:
- // if c.instance.IsShuttingDown() {
- // c.mgr.Warn("Skipping pause during shutdown")
- // return nil
- // }
- // err := c.instance.SPNGroup().Stop()
- // if err != nil {
- // return err
- // }
- // c.mgr.Info("SPN paused")
-
- c.isPausedSPN = true
+ c.pauseInfo.SPN = true
}
}
if onlySPN {
return nil
}
- if c.isPaused {
- return nil
- }
- modulesToResume := []mgr.Module{
- c.instance.Compat(),
- c.instance.Interception(),
- }
- for _, m := range modulesToResume {
- if err := m.Stop(); err != nil {
- return err
+ if !c.pauseInfo.Interception {
+ modulesToResume := []mgr.Module{
+ c.instance.Compat(),
+ c.instance.Interception(),
+ }
+ for _, m := range modulesToResume {
+ if err := m.Stop(); err != nil {
+ return err
+ }
}
- }
- c.mgr.Info("interception paused")
- c.isPaused = true
+ c.mgr.Info("interception paused")
+ c.pauseInfo.Interception = true
+ }
return nil
}
-func (c *Control) impl_resume() error {
+func (c *Control) resume() (retErr error) {
+ if c.instance.IsShuttingDown() {
+ c.mgr.Debug("Cannot resume: system is shutting down")
+ return nil
+ }
+
c.locker.Lock()
defer c.locker.Unlock()
+ defer func() {
+ // update states after resume attempt
+ c.updateStatesAndNotify()
+ // log error if resume failed
+ if retErr != nil {
+ c.mgr.Error("Failed to resume: " + retErr.Error())
+ }
+ }()
+
c.stopResumeWorker()
- if c.isPausedSPN {
-
- // TODO: consider using event to handle "spn/enable" changes:
- // module.instance.Config().EventConfigChange
- enabled := config.GetAsBool("spn/enable", false)
- if !enabled() {
- config.SetConfigOption("spn/enable", true)
- }
-
- // Alternatively, we could directly start SPN here:
- // if c.instance.IsShuttingDown() {
- // c.mgr.Warn("Skipping resume during shutdown")
- // return nil
- // }
- // if !c.instance.SPNGroup().Ready() {
- // err := c.instance.SPNGroup().Start()
- // if err != nil {
- // return err
- // }
- // c.mgr.Info("SPN resumed")
- // }
-
- c.isPausedSPN = false
- }
-
- if c.isPaused {
+ if c.pauseInfo.Interception {
modulesToResume := []mgr.Module{
c.instance.Interception(),
c.instance.Compat(),
}
-
for _, m := range modulesToResume {
if err := m.Start(); err != nil {
return err
}
}
c.mgr.Info("interception resumed")
- c.isPaused = false
+ c.pauseInfo.Interception = false
+ }
+
+ if c.pauseInfo.SPN {
+ enabled := config.GetAsBool("spn/enable", false)
+ if !enabled() {
+ config.SetConfigOption("spn/enable", true)
+ c.mgr.Info("SPN resumed")
+ }
+ c.pauseInfo.SPN = false
}
return nil
@@ -165,28 +134,90 @@ func (c *Control) impl_resume() error {
// stopResumeWorker stops any existing resume worker.
// No thread safety, caller must hold c.locker.
func (c *Control) stopResumeWorker() {
- c.pauseStartTime = time.Time{}
- c.pauseDuration = 0
+ c.pauseInfo.TillTime = time.Time{}
- if c.pauseWorker != nil {
- c.pauseWorker.Stop()
- c.pauseWorker = nil
+ if c.resumeWorker != nil {
+ c.resumeWorker.Stop()
+ c.resumeWorker = nil
}
}
// startResumeWorker starts a worker that will resume normal operation after the specified duration.
// No thread safety, caller must hold c.locker.
func (c *Control) startResumeWorker(duration time.Duration) {
- c.pauseStartTime = time.Now()
- c.pauseDuration = duration
+ c.pauseInfo.TillTime = time.Now().Add(duration)
- c.mgr.Info(fmt.Sprintf("Scheduling resume in %v", duration))
+ resumerWorkerFunc := func(wc *mgr.WorkerCtx) error {
+ wc.Info(fmt.Sprintf("Scheduling resume in %v", duration))
- c.pauseWorker = c.mgr.NewWorkerMgr(
- fmt.Sprintf("resume in %v", duration),
- func(wc *mgr.WorkerCtx) error {
- wc.Info("Resuming...")
- return c.impl_resume()
- },
- nil).Delay(duration)
+ // Subscribe to config changes to detect SPN enable.
+ cfgChangeEvt := c.instance.Config().EventConfigChange.Subscribe("control: spn enable check", 10)
+ // Make sure to cancel subscription when worker stops.
+ defer cfgChangeEvt.Cancel()
+
+ for {
+ select {
+ case <-wc.Ctx().Done():
+ return nil
+ case <-cfgChangeEvt.Events():
+ spnEnabled := config.GetAsBool("spn/enable", false)
+ if spnEnabled() {
+ wc.Info("SPN enabled by user, resuming...")
+ return c.resume()
+ }
+ case <-time.After(duration):
+ wc.Info("Resuming...")
+ return c.resume()
+ }
+ }
+ }
+
+ c.resumeWorker = c.mgr.NewWorkerMgr("resumer", resumerWorkerFunc, nil)
+ c.resumeWorker.Go()
+}
+
+// updateStatesAndNotify updates the paused states and sends notifications accordingly.
+// No thread safety, caller must hold c.locker.
+func (c *Control) updateStatesAndNotify() {
+ if !c.pauseInfo.Interception && !c.pauseInfo.SPN {
+ if c.pauseNotification != nil {
+ c.pauseNotification.Delete()
+ c.pauseNotification = nil
+ }
+ return
+ }
+
+ title := ""
+ nType := notifications.Warning
+ if c.pauseInfo.Interception && c.pauseInfo.SPN {
+ title = "Portmaster and SPN paused"
+ } else if c.pauseInfo.Interception {
+ title = "Portmaster paused"
+ } else if c.pauseInfo.SPN {
+ title = "SPN paused"
+ nType = notifications.Info // less severe notification for SPN-only pause
+ }
+ message := fmt.Sprintf("%s till %v", title, c.pauseInfo.TillTime.Format(time.TimeOnly))
+
+ c.pauseNotification = ¬ifications.Notification{
+ EventID: "control:paused",
+ Type: nType,
+ Title: title,
+ Message: message,
+ ShowOnSystem: true,
+ EventData: &c.pauseInfo,
+ AvailableActions: []*notifications.Action{
+ {
+ Text: "Resume",
+ Type: notifications.ActionTypeWebhook,
+ Payload: ¬ifications.ActionTypeWebhookPayload{
+ URL: APIEndpointResume,
+ ResultAction: "display",
+ },
+ },
+ },
+ }
+
+ notifications.Notify(c.pauseNotification)
+ c.pauseNotification.SyncWithState(c.states)
}