Merge pull request #2067 from safing/feature/2050-pause

Feature: pause PM/SPN
This commit is contained in:
Alexandr Stelnykovych
2025-11-12 14:26:40 +02:00
committed by GitHub
26 changed files with 1070 additions and 200 deletions

View File

@@ -167,6 +167,23 @@ export class PortapiService {
});
}
/** Triggers a pause of the portmaster or SPN service
* @param duration The duration of the pause in seconds
* @param onlySPN Whether or not only the SPN should be paused
*/
pause(duration: number, onlySPN: boolean): Observable<any> {
return this.http.post(`${this.httpEndpoint}/v1/control/pause`, { duration, onlySPN }, {
observe: 'response',
responseType: 'arraybuffer',
});
}
/** Triggers a resume of the portmaster (and SPN) service */
resume(): Observable<any> {
return this.http.post(`${this.httpEndpoint}/v1/control/resume`, undefined, {
observe: 'response',
responseType: 'arraybuffer',
});
}
/** Force the portmaster to check for updates */
checkForUpdates(): Observable<any> {
return this.http.post(`${this.httpEndpoint}/v1/updates/check`, undefined, {

View File

@@ -153,7 +153,7 @@
</div>
</div>
<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)"
cdkOverlayOrigin #settingsMenuTrigger="cdkOverlayOrigin" [class.active]="settingsMenu.dropdown.isOpen">
@@ -202,8 +202,47 @@
<app-menu-item (click)="cleanupHistory($event)">Cleanup Network History</app-menu-item>
</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">
<span class="text-secondary">{{ pauseInfo }} </span>
</span>
<span class="text-secondary">
Auto-resume at <span class="text-secondary">{{ pauseInfoTillTime }} </span>
</span>
</div>
<hr/>
</div>
<!-- we show 'Pause SPN...' items even when isPausedSPN to allow pause time modification-->
<app-menu-item (click)="pauseSPN($event, 60*5)" *ngIf="spnEnabled || isPausedSPN" [disabled]="isPausedInterception">Pause SPN for 5 minutes</app-menu-item>
<app-menu-item (click)="pauseSPN($event, 60*15)" *ngIf="spnEnabled || isPausedSPN" [disabled]="isPausedInterception">Pause SPN for 15 minutes</app-menu-item>
<app-menu-item (click)="pauseSPN($event, 60*60)" *ngIf="spnEnabled || isPausedSPN" [disabled]="isPausedInterception">Pause SPN for 1 hour</app-menu-item>
<hr/>
<app-menu-item (click)="pause($event, 60*5)">Pause for 5 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 now</app-menu-item>
</app-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
#powerMenuTrigger="cdkOverlayOrigin" [class.active]="powerMenu.dropdown.isOpen">

View File

@@ -1,10 +1,10 @@
import { INTEGRATION_SERVICE, IntegrationService } from 'src/app/integration';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, OnInit, Output, inject } from '@angular/core';
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting } from '@safing/portmaster-api';
import { ConfigService, DebugAPI, PortapiService, SPNService, StringSetting, BoolSetting } 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,12 +36,34 @@ 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 isPausedSPN(): boolean { return this.pauseState?.SPN===true; }
get pauseInfo(): string {
if (this.pauseState?.Interception===true && this.pauseState?.SPN===true)
return 'Portmaster and SPN are paused';
else if (this.pauseState?.Interception===true)
return 'Portmaster is paused';
else if (this.pauseState?.SPN===true)
return 'SPN is paused';
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;
/** Whether or not prompting is globally enabled. */
globalPromptingEnabled = false;
/** Whether or not the SPN is currently enabled */
spnEnabled = false;
@Output()
sideDashChange = new EventEmitter<'collapsed' | 'expanded' | 'force-overlay'>();
@@ -93,11 +115,21 @@ 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<StringSetting>('filter/defaultAction')
.subscribe(defaultAction => {
this.globalPromptingEnabled = defaultAction === 'ask';
this.cdr.markForCheck();
})
this.configService.watch<BoolSetting>("spn/enable")
.subscribe(value => {
this.spnEnabled = value;
this.cdr.markForCheck();
});
this.notificationService.new$
.subscribe(notif => {
@@ -251,6 +283,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.

View File

@@ -13,10 +13,7 @@ export class StatusService {
*/
static trackSubsystem: TrackByFunction<Subsystem> = trackById;
readonly trackSubsystem = StatusService.trackSubsystem;
readonly statusPrefix = "runtime:"
readonly subsystemPrefix = this.statusPrefix + "subsystems/"
/**
* status$ watches the global core status. It's mutlicasted using a BehaviorSubject so new
* subscribers will automatically get the latest version while only one subscription
@@ -48,48 +45,4 @@ export class StatusService {
SelectedSecurityLevel: securityLevel,
});
}
/**
* Loads the current status of a subsystem.
*
* @param name The ID of the subsystem
*/
getSubsystemStatus(id: string): Observable<Subsystem> {
return this.portapi.get(this.subsystemPrefix + id);
}
/**
* Loads the current status of all subsystems matching idPrefix.
* If idPrefix is an empty string all subsystems are returned.
*
* @param idPrefix An optional ID prefix to limit the returned subsystems
*/
querySubsystem(idPrefix: string = ''): Observable<Subsystem[]> {
return this.portapi.query<Subsystem>(this.subsystemPrefix + idPrefix)
.pipe(
map(reply => reply.data),
toArray(),
)
}
/**
* Watch a subsystem for changes. Completes when the subsystem is
* deleted. See {@method PortAPI.watch} for more information.
*
* @param id The ID of the subsystem to watch.
* @param opts Additional options for portapi.watch().
*/
watchSubsystem(id: string, opts?: WatchOpts): Observable<Subsystem> {
return this.portapi.watch(this.subsystemPrefix + id, opts);
}
/**
* Watch for subsystem changes
*
* @param opts Additional options for portapi.sub().
*/
watchSubsystems(opts?: RetryableOpts): Observable<Subsystem[]> {
return this.portapi.watchAll<Subsystem>(this.subsystemPrefix, opts);
}
}

View File

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

View File

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

View File

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

View File

@@ -859,8 +859,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [
"android-tzdata",
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -3949,6 +3951,7 @@ version = "2.0.25"
dependencies = [
"assert_matches",
"cached",
"chrono",
"clap_lex",
"ctor",
"dark-light",

View File

@@ -52,6 +52,7 @@ reqwest = { version = "0.12", features = ["cookies", "json"] }
rfd = { version = "*", default-features = false, features = [ "tokio", "gtk3", "common-controls-v6" ] }
open = "5.1.3"
chrono = { version = "0.4", features = ["serde"] }
dark-light = { path = "../rust-dark-light" }

View File

@@ -1,4 +1,4 @@
pub mod config;
pub mod spn;
pub mod notification;
pub mod subsystem;
pub mod system_status_types;

View File

@@ -1,45 +0,0 @@
#![allow(dead_code)]
use serde::*;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct ModuleStatus {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Enabled")]
pub enabled: bool,
#[serde(rename = "Status")]
pub status: u8,
#[serde(rename = "FailureStatus")]
pub failure_status: u8,
#[serde(rename = "FailureID")]
pub failure_id: String,
#[serde(rename = "FailureMsg")]
pub failure_msg: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Subsystem {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Description")]
pub description: String,
#[serde(rename = "Modules")]
pub module_status: Vec<ModuleStatus>,
#[serde(rename = "FailureStatus")]
pub failure_status: u8,
}
pub const FAILURE_NONE: u8 = 0;
pub const FAILURE_HINT: u8 = 1;
pub const FAILURE_WARNING: u8 = 2;
pub const FAILURE_ERROR: u8 = 3;

View File

@@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StateType {
#[serde(rename = "")]
Undefined,
#[serde(rename = "hint")]
Hint,
#[serde(rename = "warning")]
Warning,
#[serde(rename = "error")]
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Message")]
pub message: Option<String>,
#[serde(rename = "Type")]
pub state_type: Option<StateType>,
#[serde(rename = "Time")]
pub time: Option<String>, // time.Time serialized by GoLang
#[serde(rename = "Data")]
pub data: Option<serde_json::Value>, // any type
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateUpdate {
#[serde(rename = "Module")]
pub module: String,
#[serde(rename = "States")]
pub states: Option<Vec<State>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorstState {
#[serde(rename = "Module")]
pub module: String,
#[serde(flatten)]
pub state: State,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SystemStatus {
#[serde(rename = "Modules")]
pub modules: Vec<StateUpdate>,
#[serde(rename = "WorstState")]
pub worst_state: Option<WorstState>,
// add more fields when needed
// ...
}
// PauseInfo represents pause status data from "control:paused" state in Control module
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PauseInfo {
#[serde(rename = "Interception")]
pub interception: bool,
#[serde(rename = "SPN")]
pub spn: bool,
#[serde(rename = "TillTime")]
pub till_time: String, // time.Time serialized as string by GoLang
}
impl SystemStatus {
pub fn get_module_state(&self, module_name: &str, state_id: &str) -> Option<&State> {
if let Some(module) = self.modules.iter().find(|m| m.module == module_name) {
if let Some(states) = &module.states {
return states.iter().find(|s| s.id == state_id);
}
}
None
}
}

View File

@@ -217,6 +217,47 @@ impl<R: Runtime> PortmasterInterface<R> {
});
}
pub fn set_resume(&self) {
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
match client
.post(format!("{}control/resume", PORTMASTER_BASE_URL))
.send()
.await
{
Ok(v) => {
debug!("resume request sent {:?}", v);
}
Err(err) => {
error!("failed to send resume request {}", err);
}
}
});
}
pub fn set_pause(&self, duration_seconds: u64, spn_only: bool) {
tauri::async_runtime::spawn(async move {
let client = reqwest::Client::new();
match client
.post(format!("{}control/pause", PORTMASTER_BASE_URL))
.json(&serde_json::json!({
"duration": duration_seconds,
"onlySPN": spn_only
}))
.send()
.await
{
Ok(v) => {
debug!("pause request sent {:?}", v);
}
Err(err) => {
error!("failed to send pause request {}", err);
}
}
});
}
//// Internal functions
fn start_notification_handler(&self) {
if let Some(api) = self.get_api() {

View File

@@ -134,8 +134,10 @@ pub async fn show_notification(cli: &PortAPI, key: String, n: Notification) {
))
.await;
});
} else {
error!("Notification clicked but no action associated.");
// TODO(vladimir): If Action is None, the user clicked on the notification. Focus on the UI.
}
// TODO(vladimir): If Action is None, the user clicked on the notification. Focus on the UI.
Ok(())
});
}

View File

@@ -1,7 +1,8 @@
use std::ops::Deref;
use std::sync::atomic::AtomicBool;
use std::sync::RwLock;
use std::{collections::HashMap, sync::atomic::Ordering};
use std::{sync::atomic::Ordering};
use chrono::{DateTime};
use log::{debug, error};
use tauri::{
@@ -16,11 +17,11 @@ use crate::config;
use crate::{
portapi::{
client::PortAPI,
message::ParseError,
message::{ParseError},
models::{
config::BooleanValue,
spn::SPNStatus,
subsystem::{self, Subsystem},
system_status_types::{self, SystemStatus},
},
types::{Request, Response},
},
@@ -59,6 +60,16 @@ const FORCE_SHOW_KEY: &str = "force-show";
const PM_TRAY_ICON_ID: &str = "pm_icon";
const PM_TRAY_MENU_ID: &str = "pm_tray_menu";
const PAUSE_SPN_5_KEY: &str = "pause_spn_5";
const PAUSE_SPN_15_KEY: &str = "pause_spn_15";
const PAUSE_SPN_60_KEY: &str = "pause_spn_60";
const PAUSE_PM_5_KEY: &str = "pause_pm_5";
const PAUSE_PM_15_KEY: &str = "pause_pm_15";
const PAUSE_PM_60_KEY: &str = "pause_pm_60";
const RESUME_KEY: &str = "resume_all";
const PAUSE_INFO_KEY: &str = "pause_info";
const PAUSE_INFO_TIME_KEY: &str = "pause_info_time";
// Icons
fn get_theme_mode() -> dark_light::Mode {
@@ -125,6 +136,7 @@ fn build_tray_menu(
app: &tauri::AppHandle,
status: &str,
spn_status_text: &str,
pause_info: &system_status_types::PauseInfo,
) -> core::result::Result<ContextMenu, Box<dyn std::error::Error>> {
load_theme(app);
@@ -132,26 +144,51 @@ fn build_tray_menu(
let exit_ui_btn = MenuItemBuilder::with_id(EXIT_UI_KEY, "Exit UI").build(app)?;
let shutdown_btn = MenuItemBuilder::with_id(SHUTDOWN_KEY, "Shut Down Portmaster").build(app)?;
let global_status = MenuItemBuilder::with_id(GLOBAL_STATUS_KEY, format!("Status: {}", status))
// Global status
let global_status_text = if pause_info.interception {
format!("Status: {} (PAUSED)", status)
} else {
format!("Status: {}", status)
};
let global_status = MenuItemBuilder::with_id(GLOBAL_STATUS_KEY, global_status_text)
.enabled(false)
.build(app)
.unwrap();
// Setup SPN status
let spn_status = MenuItemBuilder::with_id(SPN_STATUS_KEY, format!("SPN: {}", spn_status_text))
.enabled(false)
.build(app)
.unwrap();
// Pause items
let (pause_status_item, pause_status_time_item, resume_item) = if pause_info.interception || pause_info.spn {
let status_text = match (pause_info.interception, pause_info.spn) {
(true, true) => "Portmaster and SPN are paused",
(true, false) => "Portmaster is paused",
(false, true) => "SPN is paused",
_ => unreachable!(), // We already checked at least one is true
};
let status_item = MenuItemBuilder::with_id(PAUSE_INFO_KEY, status_text).enabled(false).build(app)?;
let formatted_time = DateTime::parse_from_rfc3339(&pause_info.till_time)
.map(|dt| dt.with_timezone(&chrono::Local).format("%H:%M:%S").to_string())
.unwrap_or_else(|_| pause_info.till_time.clone());
let time_item = MenuItemBuilder::with_id(PAUSE_INFO_TIME_KEY, format!("Auto-resume at {}", formatted_time)).enabled(false).build(app)?;
let resume_item = MenuItemBuilder::with_id(RESUME_KEY, "Resume now").build(app)?;
(Some(status_item), Some(time_item), Some(resume_item))
} else {
(None, None, None)
};
// Setup SPN button
let spn_button_text = match spn_status_text {
"disabled" => "Enable SPN",
_ => "Disable SPN",
};
};
let spn_status = MenuItemBuilder::with_id(SPN_STATUS_KEY, format!("SPN: {}", spn_status_text))
.enabled(false)
.build(app)
.unwrap();
let spn_button = MenuItemBuilder::with_id(SPN_BUTTON_KEY, spn_button_text)
.build(app)
.unwrap();
// Setup Icon theme submenu
let system_theme = MenuItemBuilder::with_id(SYSTEM_THEME_KEY, "System")
.build(app)
.unwrap();
@@ -165,27 +202,74 @@ fn build_tray_menu(
.items(&[&system_theme, &light_theme, &dark_theme])
.build()?;
// Setup Pause/Resume menu items
let disabled_spn_pause = (spn_status_text == "disabled" && !pause_info.spn) || pause_info.interception;
let pause_spn_5min_item = MenuItemBuilder::with_id(PAUSE_SPN_5_KEY, "Pause SPN for 5 minutes").enabled(!disabled_spn_pause).build(app)?;
let pause_spn_15min_item = MenuItemBuilder::with_id(PAUSE_SPN_15_KEY, "Pause SPN for 15 minutes").enabled(!disabled_spn_pause).build(app)?;
let pause_spn_1hour_item = MenuItemBuilder::with_id(PAUSE_SPN_60_KEY, "Pause SPN for 1 hour").enabled(!disabled_spn_pause).build(app)?;
let pause_pm_5min_item = MenuItemBuilder::with_id(PAUSE_PM_5_KEY, "Pause for 5 minutes").build(app)?;
let pause_pm_15min_item = MenuItemBuilder::with_id(PAUSE_PM_15_KEY, "Pause for 15 minutes").build(app)?;
let pause_pm_1hour_item = MenuItemBuilder::with_id(PAUSE_PM_60_KEY, "Pause for 1 hour").build(app)?;
let pause_menu = SubmenuBuilder::new(app, "Pause")
.items(&[
&pause_spn_5min_item,
&pause_spn_15min_item,
&pause_spn_1hour_item,
&PredefinedMenuItem::separator(app)?,
&pause_pm_5min_item,
&pause_pm_15min_item,
&pause_pm_1hour_item,
])
.build()?;
/* DEV MENU
let force_show_window = MenuItemBuilder::with_id(FORCE_SHOW_KEY, "Force Show UI").build(app)?;
let reload_btn = MenuItemBuilder::with_id(RELOAD_KEY, "Reload User Interface").build(app)?;
let developer_menu = SubmenuBuilder::new(app, "Developer")
.items(&[&reload_btn, &force_show_window])
.build()?;
*/
// Assemble menu items
let s = PredefinedMenuItem::separator(app)?;
let mut items: Vec<&dyn tauri::menu::IsMenuItem<Wry>> = Vec::new();
items.push(&global_status);
items.push(&s);
if let Some(ref pause_status_item) = pause_status_item {
items.push(pause_status_item);
}
if let Some(ref pause_status_time_item) = pause_status_time_item {
items.push(pause_status_time_item);
}
if let Some(ref resume_item) = resume_item {
items.push(resume_item);
}
items.push(&pause_menu);
items.push(&s);
items.push(&spn_status);
items.push(&spn_button);
items.push(&s);
items.push(&theme_menu);
items.push(&s);
items.push(&open_btn);
items.push(&s);
items.push(&exit_ui_btn);
items.push(&shutdown_btn);
//items.push(&developer_menu);
let menu = MenuBuilder::with_id(app, PM_TRAY_MENU_ID)
.items(&[
&open_btn,
&PredefinedMenuItem::separator(app)?,
&global_status,
&PredefinedMenuItem::separator(app)?,
&spn_status,
&spn_button,
&PredefinedMenuItem::separator(app)?,
&theme_menu,
&PredefinedMenuItem::separator(app)?,
&exit_ui_btn,
&shutdown_btn,
&developer_menu,
])
.items(&items)
.build()?;
return Ok(menu);
@@ -194,7 +278,7 @@ fn build_tray_menu(
pub fn setup_tray_menu(
app: &mut tauri::App,
) -> core::result::Result<AppIcon, Box<dyn std::error::Error>> {
let menu = build_tray_menu(app.handle(), "Secured", "disabled")?;
let menu = build_tray_menu(app.handle(), "unknown", "disabled", &system_status_types::PauseInfo::default())?;
let icon = TrayIconBuilder::with_id(PM_TRAY_ICON_ID)
.icon(Image::from_bytes(get_red_icon()).unwrap())
@@ -250,6 +334,15 @@ pub fn setup_tray_menu(
SYSTEM_THEME_KEY => update_icon_theme(app, dark_light::Mode::Default),
DARK_THEME_KEY => update_icon_theme(app, dark_light::Mode::Dark),
LIGHT_THEME_KEY => update_icon_theme(app, dark_light::Mode::Light),
PAUSE_SPN_5_KEY => app.portmaster().set_pause(60*5, true),
PAUSE_SPN_15_KEY => app.portmaster().set_pause(60*15, true),
PAUSE_SPN_60_KEY => app.portmaster().set_pause(60*60, true),
PAUSE_PM_5_KEY => app.portmaster().set_pause(60*5, false),
PAUSE_PM_15_KEY => app.portmaster().set_pause(60*15, false),
PAUSE_PM_60_KEY => app.portmaster().set_pause(60*60, false),
RESUME_KEY => app.portmaster().set_resume(),
other => {
error!("unknown menu event id: {}", other);
}
@@ -275,36 +368,35 @@ pub fn setup_tray_menu(
Ok(icon)
}
pub fn update_icon(icon: AppIcon, subsystems: HashMap<String, Subsystem>, spn_status: String) {
// iterate over the subsystems and check if there's a module failure
let failure = subsystems.values().map(|s| &s.module_status).fold(
(subsystem::FAILURE_NONE, "".to_string()),
|mut acc, s| {
for m in s {
if m.failure_status > acc.0 {
acc = (m.failure_status, m.failure_msg.clone())
}
}
acc
},
);
pub fn update_icon(icon: AppIcon, system_status: SystemStatus, spn_status: String) {
// Extract the worst state type
let worst_state_type = system_status.worst_state
.as_ref()
.and_then(|ws| ws.state.state_type.clone())
.unwrap_or(system_status_types::StateType::Undefined);
let mut status = "Secured".to_owned();
if failure.0 != subsystem::FAILURE_NONE {
status = failure.1;
}
let icon_color = match failure.0 {
subsystem::FAILURE_WARNING => IconColor::Yellow,
subsystem::FAILURE_ERROR => IconColor::Red,
_ => match spn_status.as_str() {
"connected" | "connecting" => IconColor::Blue,
_ => IconColor::Green,
},
// Determine status and icon color in a single match expression
let (status, icon_color) = match worst_state_type {
system_status_types::StateType::Error => ("Insecure", IconColor::Red),
system_status_types::StateType::Warning => ("Insecure", IconColor::Yellow),
_ => {
let color = match spn_status.as_str() {
"connected" | "connecting" => IconColor::Blue,
_ => IconColor::Green,
};
("Secured", color)
}
};
if let Ok(menu) = build_tray_menu(icon.app_handle(), status.as_ref(), spn_status.as_str()) {
// Extract pause info from system status
let pause_info = system_status
.get_module_state("Control", "control:paused")
.and_then(|state| state.data.as_ref())
.and_then(|data| serde_json::from_value::<system_status_types::PauseInfo>(data.clone()).ok())
.unwrap_or_default();
// Rebuild and set the tray menu
if let Ok(menu) = build_tray_menu(icon.app_handle(), status, spn_status.as_str(), &pause_info) {
if let Err(err) = icon.set_menu(Some(menu)) {
error!("failed to set menu on tray icon: {}", err.to_string());
}
@@ -322,16 +414,16 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
}
};
let mut subsystem_subscription = match cli
let mut system_status_subscription = match cli
.request(Request::QuerySubscribe(
"query runtime:subsystems/".to_string(),
"query runtime:system/status".to_string(),
))
.await
{
Ok(rx) => rx,
Err(err) => {
error!(
"cancel try_handler: failed to subscribe to 'runtime:subsystems': {}",
"cancel try_handler: failed to subscribe to 'runtime:system/status': {}",
err
);
return;
@@ -388,12 +480,12 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
update_icon_color(&icon, IconColor::Blue);
let mut subsystems: HashMap<String, Subsystem> = HashMap::new();
let mut system_status = SystemStatus::default();
let mut spn_status: String = "".to_string();
loop {
tokio::select! {
msg = subsystem_subscription.recv() => {
msg = system_status_subscription.recv() => {
let msg = match msg {
Some(m) => m,
None => { break }
@@ -407,17 +499,17 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
};
if let Some((_, payload)) = res {
match payload.parse::<Subsystem>() {
Ok(n) => {
subsystems.insert(n.id.clone(), n);
update_icon(icon.clone(), subsystems.clone(), spn_status.clone());
match payload.parse::<SystemStatus>() {
Ok(system_status_update) => {
system_status.clone_from(&system_status_update);
update_icon(icon.clone(), system_status.clone(), spn_status.clone());
},
Err(err) => match err {
ParseError::Json(err) => {
error!("failed to parse subsystem: {}", err);
error!("failed to parse SystemStatus: {}", err);
}
_ => {
error!("unknown error when parsing notifications payload");
error!("unknown error when parsing SystemStatus payload");
}
},
}
@@ -441,7 +533,7 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
Ok(value) => {
debug!("SPN status update: {}", value.status);
spn_status.clone_from(&value.status);
update_icon(icon.clone(), subsystems.clone(), spn_status.clone());
update_icon(icon.clone(), system_status.clone(), spn_status.clone());
},
Err(err) => match err {
ParseError::Json(err) => {
@@ -502,7 +594,18 @@ pub async fn tray_handler(cli: PortAPI, app: tauri::AppHandle) {
}
}
}
update_icon_nostate(icon.clone());
}
pub fn update_icon_nostate(icon: AppIcon) {
update_icon_color(&icon, IconColor::Red);
if let Ok(menu) = build_tray_menu(icon.app_handle(), "unknown", "unknown", &system_status_types::PauseInfo::default()) {
if let Err(err) = icon.set_menu(Some(menu)) {
error!("failed to set menu on tray icon: {}", err.to_string());
}
}
}
fn update_icon_color(icon: &AppIcon, new_color: IconColor) {

View File

@@ -78,12 +78,20 @@ func prep() error {
}
func start() error {
// Create fresh worker managers on Start() to enable clean restarts after Stop().
// (after module.Manager().Stop() has been called, all workers are stopped and cannot be reused)
module.selfcheckWorkerMgr = module.Manager().NewWorkerMgr("compatibility self-check", selfcheckTaskFunc, nil)
module.cleanNotifyThresholdWorkerMgr = module.Manager().NewWorkerMgr("clean notify thresholds", cleanNotifyThreshold, nil)
startNotify()
selfcheckNetworkChangedFlag.Refresh()
// Schedule periodic self-checks.
module.selfcheckWorkerMgr.Repeat(5 * time.Minute).Delay(selfcheckTaskRetryAfter)
module.cleanNotifyThresholdWorkerMgr.Repeat(1 * time.Hour)
// Add network change callback to trigger immediate self-check.
module.instance.NetEnv().EventNetworkChange.AddCallback("trigger compat self-check", func(_ *mgr.WorkerCtx, _ struct{}) (bool, error) {
module.selfcheckWorkerMgr.Delay(selfcheckTaskRetryAfter)
return false, nil
@@ -92,13 +100,12 @@ func start() error {
}
func stop() error {
// selfcheckTask.Cancel()
// selfcheckTask = nil
resetSelfCheckState()
return nil
}
func selfcheckTaskFunc(wc *mgr.WorkerCtx) error {
// Create tracing logger.
ctx, tracer := log.AddTracer(wc.Ctx())
defer tracer.Submit()
@@ -138,12 +145,16 @@ func selfcheckTaskFunc(wc *mgr.WorkerCtx) error {
}
// Reset self-check state.
resetSelfCheckState()
return nil
}
func resetSelfCheckState() {
selfcheckNetworkChangedFlag.Refresh()
selfCheckIsFailing.UnSet()
selfcheckFails = 0
resetSystemIssue()
return nil
}
// SelfCheckIsFailing returns whether the self check is currently failing.
@@ -167,11 +178,7 @@ func New(instance instance) (*Compat, error) {
module = &Compat{
mgr: m,
instance: instance,
selfcheckWorkerMgr: m.NewWorkerMgr("compatibility self-check", selfcheckTaskFunc, nil),
cleanNotifyThresholdWorkerMgr: m.NewWorkerMgr("clean notify thresholds", cleanNotifyThreshold, nil),
states: mgr.NewStateMgr(m),
states: mgr.NewStateMgr(m),
}
if err := prep(); err != nil {
return nil, err

86
service/control/api.go Normal file
View File

@@ -0,0 +1,86 @@
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
}
func (c *Control) registerAPIEndpoints() error {
if err := api.RegisterEndpoint(api.Endpoint{
Path: APIEndpointPause,
Write: api.PermitAdmin,
ActionFunc: c.handlePause,
Name: "Pause Portmaster",
Description: "Pause the Portmaster Core Service.",
Parameters: []api.Parameter{
{
Method: http.MethodPost,
Field: "duration",
Description: "Specify the duration to pause the service in seconds.",
},
{
Method: http.MethodPost,
Field: "onlySPN",
Value: "false",
Description: "Specify whether to pause only the SPN service.",
}},
}); err != nil {
return err
}
if err := api.RegisterEndpoint(api.Endpoint{
Path: APIEndpointResume,
Write: api.PermitAdmin,
ActionFunc: c.handleResume,
Name: "Resume Portmaster",
Description: "Resume the Portmaster Core Service.",
}); err != nil {
return err
}
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, &params); 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
}

80
service/control/module.go Normal file
View File

@@ -0,0 +1,80 @@
package control
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/safing/portmaster/base/config"
"github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service/mgr"
)
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
resumeWorker *mgr.WorkerMgr
pauseNotification *notifications.Notification
pauseInfo PauseInfo
}
type instance interface {
Config() *config.Config
InterceptionGroup() *mgr.GroupModule
IsShuttingDown() bool
}
var (
singleton atomic.Bool
)
func New(instance instance) (*Control, error) {
if !singleton.CompareAndSwap(false, true) {
return nil, fmt.Errorf("control: New failed: instance already created")
}
m := mgr.New("Control")
module := &Control{
mgr: m,
instance: instance,
states: mgr.NewStateMgr(m),
}
if err := module.prep(); err != nil {
return nil, err
}
return module, nil
}
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
}
func (c *Control) Stop() error {
c.locker.Lock()
defer c.locker.Unlock()
c.stopResumeWorker()
return nil
}
func (c *Control) prep() error {
return c.registerAPIEndpoints()
}

230
service/control/pause.go Normal file
View File

@@ -0,0 +1,230 @@
package control
import (
"errors"
"fmt"
"time"
"github.com/safing/portmaster/base/config"
"github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service/mgr"
)
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
}
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("invalid pause duration")
}
spn_enabled := config.GetAsBool("spn/enable", false)
if onlySPN {
if c.pauseInfo.Interception {
return errors.New("cannot pause SPN separately when core is paused")
}
// If SPN is not running and not already paused, cannot pause it or change pause duration.
if !spn_enabled() && !c.pauseInfo.SPN {
return errors.New("cannot pause SPN when it is not running")
}
}
// Stop resume worker if running and start a new one later.
c.stopResumeWorker()
defer func() {
if retErr == nil {
// start new resume worker (with new duration) if no error
c.startResumeWorker(duration)
}
}()
// Pause SPN if not already paused.
if !c.pauseInfo.SPN {
if spn_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")
c.pauseInfo.SPN = true
}
}
if onlySPN {
return nil
}
if !c.pauseInfo.Interception {
if err := c.instance.InterceptionGroup().Stop(); err != nil {
return err
}
c.mgr.Info("interception paused")
c.pauseInfo.Interception = true
}
return nil
}
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.pauseInfo.Interception {
if err := c.instance.InterceptionGroup().Start(); err != nil {
return err
}
c.mgr.Info("interception resumed")
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
}
// stopResumeWorker stops any existing resume worker.
// No thread safety, caller must hold c.locker.
func (c *Control) stopResumeWorker() {
c.pauseInfo.TillTime = time.Time{}
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.pauseInfo.TillTime = time.Now().Add(duration)
resumerWorkerFunc := func(wc *mgr.WorkerCtx) error {
wc.Info(fmt.Sprintf("Scheduling resume in %v", 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...")
err := c.resume()
if err == nil {
n := &notifications.Notification{
EventID: "control:resumed",
Type: notifications.Info,
Title: "Resumed",
Message: "Automatically resumed from pause state",
ShowOnSystem: true,
Expires: time.Now().Add(15 * time.Second).Unix(),
AvailableActions: []*notifications.Action{
{
ID: "ack",
Text: "OK",
},
},
}
notifications.Notify(n)
}
return err
}
}
}
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 = &notifications.Notification{
EventID: "control:paused",
Type: nType,
Title: title,
Message: message,
ShowOnSystem: false, // TODO: Before enabling, ensure that UI client (Tauri implementation) supports ActionTypeWebhook.
EventData: &c.pauseInfo,
AvailableActions: []*notifications.Action{
{
Text: "Resume",
Type: notifications.ActionTypeWebhook,
Payload: &notifications.ActionTypeWebhookPayload{
URL: APIEndpointResume,
ResultAction: "display",
},
},
},
}
notifications.Notify(c.pauseNotification)
c.pauseNotification.SyncWithState(c.states)
}

View File

@@ -24,10 +24,26 @@ func (i *Instance) GetWorkerInfo() (*mgr.WorkerInfo, error) {
for _, m := range i.serviceGroup.Modules() {
wi, _ := m.Manager().WorkerInfo(snapshot) // Does not fail when we provide a snapshot.
infos = append(infos, wi)
// Check if module is a nested modules group
if gm, ok := m.(*mgr.GroupModule); ok {
for _, sm := range gm.Modules() {
wi, _ := sm.Manager().WorkerInfo(snapshot) // Does not fail when we provide a snapshot.
infos = append(infos, wi)
}
}
}
for _, m := range i.SpnGroup.Modules() {
wi, _ := m.Manager().WorkerInfo(snapshot) // Does not fail when we provide a snapshot.
infos = append(infos, wi)
// Check if module is a nested modules group
if gm, ok := m.(*mgr.GroupModule); ok {
for _, sm := range gm.Modules() {
wi, _ := sm.Manager().WorkerInfo(snapshot) // Does not fail when we provide a snapshot.
infos = append(infos, wi)
}
}
}
return mgr.MergeWorkerInfo(infos...), nil

View File

@@ -12,9 +12,7 @@ import (
var (
packetMetricsDestination string
metrics = &packetMetrics{
done: make(chan struct{}),
}
metrics = &packetMetrics{}
)
func init() {
@@ -40,6 +38,10 @@ func (pm *packetMetrics) record(tp *tracedPacket, verdict string) {
pm.l.Lock()
defer pm.l.Unlock()
if pm.done == nil {
return
}
pm.records = append(pm.records, &performanceRecord{
start: start,
duration: duration,
@@ -48,6 +50,18 @@ func (pm *packetMetrics) record(tp *tracedPacket, verdict string) {
}(tp.start.UnixNano(), time.Since(tp.start))
}
func (pm *packetMetrics) stop() {
pm.l.Lock()
defer pm.l.Unlock()
if pm.done == nil {
return
}
close(pm.done)
pm.done = nil
}
func (pm *packetMetrics) writeMetrics() {
if packetMetricsDestination == "" {
return
@@ -62,9 +76,14 @@ func (pm *packetMetrics) writeMetrics() {
_ = f.Close()
}()
pm.l.Lock()
pm.done = make(chan struct{})
done := pm.done
pm.l.Unlock()
for {
select {
case <-pm.done:
case <-done:
return
case <-time.After(time.Second * 5):
}

View File

@@ -40,6 +40,7 @@ var (
BandwidthUpdates = make(chan *packet.BandwidthUpdate, 1000)
disableInterception bool
isStarted atomic.Bool
)
func init() {
@@ -53,6 +54,10 @@ func start() error {
return nil
}
if !isStarted.CompareAndSwap(false, true) {
return nil // already running
}
inputPackets := Packets
if packetMetricsDestination != "" {
go metrics.writeMetrics()
@@ -64,7 +69,17 @@ func start() error {
}()
}
return startInterception(inputPackets)
err := startInterception(inputPackets)
if err != nil {
log.Errorf("interception: failed to start module: %q", err)
log.Debug("interception: cleaning up after failed start...")
metrics.stop()
if e := stopInterception(); e != nil {
log.Debugf("interception: error cleaning up after failed start: %q", e.Error())
}
isStarted.Store(false)
}
return err
}
// Stop starts the interception.
@@ -73,7 +88,11 @@ func stop() error {
return nil
}
close(metrics.done)
if !isStarted.CompareAndSwap(true, false) {
return nil // not running
}
metrics.stop()
if err := stopInterception(); err != nil {
log.Errorf("failed to stop interception module: %s", err)
}

View File

@@ -1,10 +1,10 @@
package interception
import (
"flag"
"fmt"
"sort"
"strings"
"sync/atomic"
"github.com/coreos/go-iptables/iptables"
"github.com/hashicorp/go-multierror"
@@ -30,15 +30,10 @@ var (
out6Queue nfQueue
in6Queue nfQueue
isRunning atomic.Bool
shutdownSignal = make(chan struct{})
experimentalNfqueueBackend bool
)
func init() {
flag.BoolVar(&experimentalNfqueueBackend, "experimental-nfqueue", false, "(deprecated flag; always used)")
}
// nfQueue encapsulates nfQueue providers.
type nfQueue interface {
PacketChannel() <-chan packet.Packet
@@ -262,12 +257,13 @@ func deactivateIPTables(protocol iptables.Protocol, rules, chains []string) erro
// StartNfqueueInterception starts the nfqueue interception.
func StartNfqueueInterception(packets chan<- packet.Packet) (err error) {
// @deprecated, remove in v1
if experimentalNfqueueBackend {
log.Warningf("[DEPRECATED] --experimental-nfqueue has been deprecated as the backend is now used by default")
log.Warningf("[DEPRECATED] please remove the flag from your configuration!")
if !isRunning.CompareAndSwap(false, true) {
return nil // already running
}
// Reset shutdown signal
shutdownSignal = make(chan struct{})
err = activateNfqueueFirewall()
if err != nil {
return fmt.Errorf("could not initialize nfqueue: %w", err)
@@ -305,6 +301,11 @@ func StartNfqueueInterception(packets chan<- packet.Packet) (err error) {
// StopNfqueueInterception stops the nfqueue interception.
func StopNfqueueInterception() error {
if !isRunning.CompareAndSwap(true, false) {
return nil // not running
}
// Signal shutdown to packet handler
defer close(shutdownSignal)
if out4Queue != nil {

View File

@@ -16,6 +16,7 @@ import (
"github.com/safing/portmaster/base/utils"
"github.com/safing/portmaster/service/broadcasts"
"github.com/safing/portmaster/service/compat"
"github.com/safing/portmaster/service/control"
"github.com/safing/portmaster/service/core"
"github.com/safing/portmaster/service/core/base"
"github.com/safing/portmaster/service/firewall"
@@ -57,7 +58,8 @@ type Instance struct {
shutdownCtx context.Context
cancelShutdownCtx context.CancelFunc
serviceGroup *mgr.Group
serviceGroup *mgr.Group
serviceGroupInterception *mgr.GroupModule
binDir string
dataDir string
@@ -95,6 +97,7 @@ type Instance struct {
process *process.ProcessModule
resolver *resolver.ResolverModule
sync *sync.Sync
control *control.Control
access *access.Access
@@ -264,6 +267,10 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx
if err != nil {
return instance, fmt.Errorf("create sync module: %w", err)
}
instance.control, err = control.New(instance)
if err != nil {
return instance, fmt.Errorf("create control module: %w", err)
}
instance.access, err = access.New(instance)
if err != nil {
return instance, fmt.Errorf("create access module: %w", err)
@@ -307,6 +314,12 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx
return instance, fmt.Errorf("create terminal module: %w", err)
}
// Grouped interception modules that can be paused/resumed together.
instance.serviceGroupInterception = mgr.NewGroupModule("Interception Group",
instance.interception,
instance.dnsmonitor,
instance.compat)
// Add all modules to instance group.
instance.serviceGroup = mgr.NewGroup(
instance.base,
@@ -334,14 +347,18 @@ func New(svcCfg *ServiceConfig) (*Instance, error) { //nolint:maintidx
instance.resolver,
instance.filterLists,
instance.customlist,
instance.interception,
instance.dnsmonitor,
instance.compat,
// Grouped pausable interception modules:
// instance.interception,
// instance.dnsmonitor,
// instance.compat
instance.serviceGroupInterception,
instance.status,
instance.broadcasts,
instance.sync,
instance.ui,
instance.control,
instance.access,
)
@@ -542,6 +559,11 @@ func (i *Instance) Interception() *interception.Interception {
return i.interception
}
// InterceptionGroup returns the grouped interception modules that can be paused together.
func (i *Instance) InterceptionGroup() *mgr.GroupModule {
return i.serviceGroupInterception
}
// DNSMonitor returns the dns-listener module.
func (i *Instance) DNSMonitor() *dnsmonitor.DNSMonitor {
return i.dnsmonitor

View File

@@ -0,0 +1,33 @@
package mgr
// GroupModule is a module that wraps a group of modules,
// to allow nesting groups of modules in parent group.
type GroupModule struct {
mgr *Manager
group *Group
}
func NewGroupModule(name string, modules ...Module) *GroupModule {
return &GroupModule{
mgr: New(name),
group: NewGroup(modules...),
}
}
func (gm *GroupModule) Manager() *Manager {
return gm.mgr
}
func (gm *GroupModule) Start() error {
return gm.group.Start()
}
func (gm *GroupModule) Stop() error {
return gm.group.Stop()
}
// Modules returns the modules in the group wrapped by this group module.
// (mimics Group.Modules())
func (gm *GroupModule) Modules() []Module {
return gm.group.Modules()
}

View File

@@ -35,10 +35,6 @@ func (s *Status) Manager() *mgr.Manager {
// Start starts the module.
func (s *Status) Start() error {
if err := s.setupRuntimeProvider(); err != nil {
return err
}
s.mgr.Go("status publisher", s.statusPublisher)
s.instance.NetEnv().EventOnlineStatusChange.AddCallback("update online status in system status",
@@ -67,6 +63,14 @@ func (s *Status) Stop() error {
return nil
}
func (s *Status) prep() error {
// register status provider as soon as possible
if err := s.setupRuntimeProvider(); err != nil {
return err
}
return nil
}
// AddToDebugInfo adds the system status to the given debug.Info.
func AddToDebugInfo(di *debug.Info) {
di.AddSection(
@@ -96,6 +100,10 @@ func New(instance instance) (*Status, error) {
notifications: make(map[string]map[string]*notifications.Notification),
}
if err := module.prep(); err != nil {
return nil, err
}
return module, nil
}