feat: pause and resume functionality improvements + UI
https://github.com/safing/portmaster/issues/2050
This commit is contained in:
@@ -153,7 +153,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-lower-list">
|
<div class="nav-lower-list">
|
||||||
<div class="relative link" sfngTipUpTrigger="navTools" sfngTipUpPassive tooltip="Version and Tools"
|
<div class="relative link" sfngTipUpTrigger="navTools" sfngTipUpPassive sfng-tooltip="Version and Tools"
|
||||||
sfngTooltipDelay="1000" snfgTooltipPosition="right" (click)="settingsMenu.dropdown.toggle(settingsMenuTrigger)"
|
sfngTooltipDelay="1000" snfgTooltipPosition="right" (click)="settingsMenu.dropdown.toggle(settingsMenuTrigger)"
|
||||||
cdkOverlayOrigin #settingsMenuTrigger="cdkOverlayOrigin" [class.active]="settingsMenu.dropdown.isOpen">
|
cdkOverlayOrigin #settingsMenuTrigger="cdkOverlayOrigin" [class.active]="settingsMenu.dropdown.isOpen">
|
||||||
|
|
||||||
@@ -202,8 +202,46 @@
|
|||||||
<app-menu-item (click)="cleanupHistory($event)">Cleanup Network History</app-menu-item>
|
<app-menu-item (click)="cleanupHistory($event)">Cleanup Network History</app-menu-item>
|
||||||
</app-menu>
|
</app-menu>
|
||||||
|
|
||||||
|
<!-- Pause Menu -->
|
||||||
|
<div sfngTipUpTrigger="navPause" sfngTipUpPassive sfng-tooltip="Pause and Resume" sfngTooltipDelay="1000"
|
||||||
|
snfgTooltipPosition="right" class="link" (click)="pauseMenu.dropdown.toggle(pauseMenuTrigger)" cdkOverlayOrigin
|
||||||
|
#pauseMenuTrigger="cdkOverlayOrigin" [class.active]="pauseMenu.dropdown.isOpen">
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" class="help" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z"></path>
|
||||||
|
<path d="M14 9L14 15"></path>
|
||||||
|
<path d="M10 9L10 15"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<app-menu #pauseMenu offsetY="0" offsetX="10" overlayClass="rounded-t">
|
||||||
|
<div *ngIf="isPaused">
|
||||||
|
<div class="flex flex-col p-4 text-xxs">
|
||||||
|
<span class="text-secondary">
|
||||||
|
Paused: <span class="text-primary">{{ pauseInfo }} </span>
|
||||||
|
</span>
|
||||||
|
<span class="text-secondary">
|
||||||
|
Auto-resume at: <span class="text-primary">{{ pauseInfoTillTime }} </span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<hr/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-menu-item (click)="pauseSPN($event, 60*3)" [disabled]="isPausedInterception">Pause SPN for 3 minutes</app-menu-item>
|
||||||
|
<app-menu-item (click)="pauseSPN($event, 60*15)" [disabled]="isPausedInterception">Pause SPN for 15 minutes</app-menu-item>
|
||||||
|
<app-menu-item (click)="pauseSPN($event, 60*60)" [disabled]="isPausedInterception">Pause SPN for 1 hour</app-menu-item>
|
||||||
|
<hr/>
|
||||||
|
<app-menu-item (click)="pause($event, 60*3)">Pause for 3 minutes</app-menu-item>
|
||||||
|
<app-menu-item (click)="pause($event, 60*15)">Pause for 15 minutes</app-menu-item>
|
||||||
|
<app-menu-item (click)="pause($event, 60*60)">Pause for 1 hour</app-menu-item>
|
||||||
|
<hr/>
|
||||||
|
<app-menu-item (click)="resume($event)" [disabled]="!isPaused">Resume</app-menu-item>
|
||||||
|
</app-menu>
|
||||||
|
|
||||||
<!-- Power Menu -->
|
<!-- Power Menu -->
|
||||||
<div sfngTipUpTrigger="navPower" sfngTipUpPassive tooltip="Shutdown and Restart" sfngTooltipDelay="1000"
|
<div sfngTipUpTrigger="navPower" sfngTipUpPassive sfng-tooltip="Shutdown and Restart" sfngTooltipDelay="1000"
|
||||||
snfgTooltipPosition="right" class="link" (click)="powerMenu.dropdown.toggle(powerMenuTrigger)" cdkOverlayOrigin
|
snfgTooltipPosition="right" class="link" (click)="powerMenu.dropdown.toggle(powerMenuTrigger)" cdkOverlayOrigin
|
||||||
#powerMenuTrigger="cdkOverlayOrigin" [class.active]="powerMenu.dropdown.isOpen">
|
#powerMenuTrigger="cdkOverlayOrigin" [class.active]="powerMenu.dropdown.isOpen">
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, In
|
|||||||
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api';
|
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { AppComponent } from 'src/app/app.component';
|
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 { ActionIndicatorService } from 'src/app/shared/action-indicator';
|
||||||
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
|
import { fadeInAnimation, fadeOutAnimation } from 'src/app/shared/animations';
|
||||||
import { ExitService } from 'src/app/shared/exit-screen';
|
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) */
|
/** The color to use for the notifcation-available hint (dot) */
|
||||||
notificationColor: string = 'text-green-300';
|
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 */
|
/** Whether or not we have new, unseen prompts */
|
||||||
hasNewPrompts = false;
|
hasNewPrompts = false;
|
||||||
|
|
||||||
@@ -93,6 +111,10 @@ export class NavigationComponent implements OnInit {
|
|||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.statusService.status$.subscribe(status => {
|
||||||
|
this.pauseState = GetModuleState(status, 'Control', 'control:paused')?.Data || null;
|
||||||
|
});
|
||||||
|
|
||||||
this.configService.watch<StringSetting>('filter/defaultAction')
|
this.configService.watch<StringSetting>('filter/defaultAction')
|
||||||
.subscribe(defaultAction => {
|
.subscribe(defaultAction => {
|
||||||
this.globalPromptingEnabled = defaultAction === 'ask';
|
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
|
* @private
|
||||||
* Opens the data-directory of the portmaster installation.
|
* Opens the data-directory of the portmaster installation.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function getOnlineStatusString(stat: OnlineStatus): string {
|
|||||||
export interface CoreStatus extends Record {
|
export interface CoreStatus extends Record {
|
||||||
OnlineStatus: OnlineStatus;
|
OnlineStatus: OnlineStatus;
|
||||||
CaptivePortal: CaptivePortal;
|
CaptivePortal: CaptivePortal;
|
||||||
// Modules: []ModuleState; // TODO: Do we need all modules?
|
Modules: StateUpdate[]; // TODO: Do we need all modules?
|
||||||
WorstState: {
|
WorstState: {
|
||||||
Module: string,
|
Module: string,
|
||||||
ID: 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 {
|
export enum ModuleStateType {
|
||||||
Undefined = "",
|
Undefined = "",
|
||||||
Hint = "hint",
|
Hint = "hint",
|
||||||
@@ -110,3 +124,55 @@ export interface VersionStatus extends Record {
|
|||||||
[key: string]: Resource
|
[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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from "@angular/core";
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Input, OnInit, inject } from "@angular/core";
|
||||||
import { SecurityLevel } from "@safing/portmaster-api";
|
import { SecurityLevel } from "@safing/portmaster-api";
|
||||||
import { combineLatest } from "rxjs";
|
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";
|
import { fadeInAnimation, fadeOutAnimation } from "../animations";
|
||||||
|
|
||||||
interface SecurityOption {
|
interface SecurityOption {
|
||||||
@@ -62,6 +62,17 @@ export class SecurityLockComponent implements OnInit {
|
|||||||
break;
|
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();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ navTools:
|
|||||||
title: Version and Tools
|
title: Version and Tools
|
||||||
content: |
|
content: |
|
||||||
View the Portmaster's version and use special actions and tools.
|
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
|
nextKey: navPower
|
||||||
|
|
||||||
navPower:
|
navPower:
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package control
|
package control
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/safing/portmaster/base/api"
|
"github.com/safing/portmaster/base/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
APIEndpointPause = "control/pause"
|
||||||
|
APIEndpointResume = "control/resume"
|
||||||
|
)
|
||||||
|
|
||||||
type pauseRequestParams struct {
|
type pauseRequestParams struct {
|
||||||
Duration int `json:"duration"` // Duration in seconds
|
Duration int `json:"duration"` // Duration in seconds
|
||||||
OnlySPN bool `json:"onlySPN"` // Whether to pause only the SPN service
|
OnlySPN bool `json:"onlySPN"` // Whether to pause only the SPN service
|
||||||
@@ -14,7 +22,7 @@ type pauseRequestParams struct {
|
|||||||
func (c *Control) registerAPIEndpoints() error {
|
func (c *Control) registerAPIEndpoints() error {
|
||||||
|
|
||||||
if err := api.RegisterEndpoint(api.Endpoint{
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
Path: "control/pause",
|
Path: APIEndpointPause,
|
||||||
Write: api.PermitAdmin,
|
Write: api.PermitAdmin,
|
||||||
ActionFunc: c.handlePause,
|
ActionFunc: c.handlePause,
|
||||||
Name: "Pause Portmaster",
|
Name: "Pause Portmaster",
|
||||||
@@ -36,7 +44,7 @@ func (c *Control) registerAPIEndpoints() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := api.RegisterEndpoint(api.Endpoint{
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
Path: "control/resume",
|
Path: APIEndpointResume,
|
||||||
Write: api.PermitAdmin,
|
Write: api.PermitAdmin,
|
||||||
ActionFunc: c.handleResume,
|
ActionFunc: c.handleResume,
|
||||||
Name: "Resume Portmaster",
|
Name: "Resume Portmaster",
|
||||||
@@ -47,3 +55,32 @@ func (c *Control) registerAPIEndpoints() error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,23 +7,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/safing/portmaster/base/config"
|
"github.com/safing/portmaster/base/config"
|
||||||
|
"github.com/safing/portmaster/base/notifications"
|
||||||
"github.com/safing/portmaster/service/compat"
|
"github.com/safing/portmaster/service/compat"
|
||||||
"github.com/safing/portmaster/service/firewall/interception"
|
"github.com/safing/portmaster/service/firewall/interception"
|
||||||
"github.com/safing/portmaster/service/mgr"
|
"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 {
|
type Control struct {
|
||||||
mgr *mgr.Manager
|
mgr *mgr.Manager
|
||||||
instance instance
|
instance instance
|
||||||
|
states *mgr.StateMgr
|
||||||
|
|
||||||
locker sync.Mutex
|
locker sync.Mutex
|
||||||
pauseWorker *mgr.WorkerMgr
|
resumeWorker *mgr.WorkerMgr
|
||||||
isPaused bool
|
pauseNotification *notifications.Notification
|
||||||
isPausedSPN bool
|
pauseInfo PauseInfo
|
||||||
pauseStartTime time.Time
|
|
||||||
pauseDuration time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type instance interface {
|
type instance interface {
|
||||||
@@ -31,6 +35,7 @@ type instance interface {
|
|||||||
Interception() *interception.Interception
|
Interception() *interception.Interception
|
||||||
Compat() *compat.Compat
|
Compat() *compat.Compat
|
||||||
SPNGroup() *mgr.ExtendedGroup
|
SPNGroup() *mgr.ExtendedGroup
|
||||||
|
IsShuttingDown() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -42,10 +47,11 @@ func New(instance instance) (*Control, error) {
|
|||||||
return nil, fmt.Errorf("control: New failed: instance already created")
|
return nil, fmt.Errorf("control: New failed: instance already created")
|
||||||
}
|
}
|
||||||
|
|
||||||
mgr := mgr.New("Control")
|
m := mgr.New("Control")
|
||||||
module := &Control{
|
module := &Control{
|
||||||
mgr: mgr,
|
mgr: m,
|
||||||
instance: instance,
|
instance: instance,
|
||||||
|
states: mgr.NewStateMgr(m),
|
||||||
}
|
}
|
||||||
if err := module.prep(); err != nil {
|
if err := module.prep(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -57,6 +63,10 @@ func (c *Control) Manager() *mgr.Manager {
|
|||||||
return c.mgr
|
return c.mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *Control) States() *mgr.StateMgr {
|
||||||
|
return u.states
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Control) Start() error {
|
func (c *Control) Start() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,43 @@
|
|||||||
package control
|
package control
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/safing/portmaster/base/api"
|
|
||||||
"github.com/safing/portmaster/base/config"
|
"github.com/safing/portmaster/base/config"
|
||||||
|
"github.com/safing/portmaster/base/notifications"
|
||||||
"github.com/safing/portmaster/service/mgr"
|
"github.com/safing/portmaster/service/mgr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *Control) handlePause(r *api.Request) (msg string, err error) {
|
func (c *Control) pause(duration time.Duration, onlySPN bool) (retErr error) {
|
||||||
params := pauseRequestParams{}
|
if c.instance.IsShuttingDown() {
|
||||||
if r.InputData != nil {
|
c.mgr.Debug("Cannot pause: system is shutting down")
|
||||||
if err := json.Unmarshal(r.InputData, ¶ms); err != nil {
|
return 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.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()
|
c.locker.Lock()
|
||||||
defer c.locker.Unlock()
|
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 {
|
if duration <= 0 {
|
||||||
return errors.New(logPrefix + "invalid pause duration")
|
return errors.New("invalid pause duration")
|
||||||
}
|
}
|
||||||
|
|
||||||
if onlySPN {
|
if onlySPN {
|
||||||
if c.isPaused {
|
if c.pauseInfo.Interception {
|
||||||
return errors.New(logPrefix + "cannot pause SPN separately when core is paused")
|
return errors.New("cannot pause SPN separately when core is paused")
|
||||||
}
|
}
|
||||||
if !c.isPausedSPN && !c.instance.SPNGroup().Ready() {
|
if !c.pauseInfo.SPN && !c.instance.SPNGroup().Ready() {
|
||||||
return errors.New(logPrefix + "cannot pause SPN when it is not running")
|
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() {
|
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)
|
enabled := config.GetAsBool("spn/enable", false)
|
||||||
if enabled() {
|
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)
|
config.SetConfigOption("spn/enable", false)
|
||||||
|
c.mgr.Info("SPN paused")
|
||||||
}
|
}
|
||||||
|
c.pauseInfo.SPN = true
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if onlySPN {
|
if onlySPN {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if c.isPaused {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
modulesToResume := []mgr.Module{
|
if !c.pauseInfo.Interception {
|
||||||
c.instance.Compat(),
|
modulesToResume := []mgr.Module{
|
||||||
c.instance.Interception(),
|
c.instance.Compat(),
|
||||||
}
|
c.instance.Interception(),
|
||||||
for _, m := range modulesToResume {
|
}
|
||||||
if err := m.Stop(); err != nil {
|
for _, m := range modulesToResume {
|
||||||
return err
|
if err := m.Stop(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
c.mgr.Info("interception paused")
|
c.mgr.Info("interception paused")
|
||||||
c.isPaused = true
|
c.pauseInfo.Interception = true
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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()
|
c.locker.Lock()
|
||||||
defer c.locker.Unlock()
|
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()
|
c.stopResumeWorker()
|
||||||
|
|
||||||
if c.isPausedSPN {
|
if c.pauseInfo.Interception {
|
||||||
|
|
||||||
// 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 {
|
|
||||||
modulesToResume := []mgr.Module{
|
modulesToResume := []mgr.Module{
|
||||||
c.instance.Interception(),
|
c.instance.Interception(),
|
||||||
c.instance.Compat(),
|
c.instance.Compat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, m := range modulesToResume {
|
for _, m := range modulesToResume {
|
||||||
if err := m.Start(); err != nil {
|
if err := m.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.mgr.Info("interception resumed")
|
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
|
return nil
|
||||||
@@ -165,28 +134,90 @@ func (c *Control) impl_resume() error {
|
|||||||
// stopResumeWorker stops any existing resume worker.
|
// stopResumeWorker stops any existing resume worker.
|
||||||
// No thread safety, caller must hold c.locker.
|
// No thread safety, caller must hold c.locker.
|
||||||
func (c *Control) stopResumeWorker() {
|
func (c *Control) stopResumeWorker() {
|
||||||
c.pauseStartTime = time.Time{}
|
c.pauseInfo.TillTime = time.Time{}
|
||||||
c.pauseDuration = 0
|
|
||||||
|
|
||||||
if c.pauseWorker != nil {
|
if c.resumeWorker != nil {
|
||||||
c.pauseWorker.Stop()
|
c.resumeWorker.Stop()
|
||||||
c.pauseWorker = nil
|
c.resumeWorker = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startResumeWorker starts a worker that will resume normal operation after the specified duration.
|
// startResumeWorker starts a worker that will resume normal operation after the specified duration.
|
||||||
// No thread safety, caller must hold c.locker.
|
// No thread safety, caller must hold c.locker.
|
||||||
func (c *Control) startResumeWorker(duration time.Duration) {
|
func (c *Control) startResumeWorker(duration time.Duration) {
|
||||||
c.pauseStartTime = time.Now()
|
c.pauseInfo.TillTime = time.Now().Add(duration)
|
||||||
c.pauseDuration = 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(
|
// Subscribe to config changes to detect SPN enable.
|
||||||
fmt.Sprintf("resume in %v", duration),
|
cfgChangeEvt := c.instance.Config().EventConfigChange.Subscribe("control: spn enable check", 10)
|
||||||
func(wc *mgr.WorkerCtx) error {
|
// Make sure to cancel subscription when worker stops.
|
||||||
wc.Info("Resuming...")
|
defer cfgChangeEvt.Cancel()
|
||||||
return c.impl_resume()
|
|
||||||
},
|
for {
|
||||||
nil).Delay(duration)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user