feat(WIP): add pause and resume functionality for Portmaster/SPN

https://github.com/safing/portmaster/issues/2050
This commit is contained in:
Alexandr Stelnykovych
2025-10-24 18:15:27 +03:00
parent 287c498bf1
commit c063bda700
9 changed files with 404 additions and 10 deletions

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

@@ -0,0 +1,49 @@
package control
import (
"net/http"
"github.com/safing/portmaster/base/api"
)
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: "control/pause",
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: "control/resume",
Write: api.PermitAdmin,
ActionFunc: c.handleResume,
Name: "Resume Portmaster",
Description: "Resume the Portmaster Core Service.",
}); err != nil {
return err
}
return nil
}

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

@@ -0,0 +1,74 @@
package control
import (
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/safing/portmaster/base/config"
"github.com/safing/portmaster/service/compat"
"github.com/safing/portmaster/service/firewall/interception"
"github.com/safing/portmaster/service/mgr"
)
var logPrefix = "control: "
type Control struct {
mgr *mgr.Manager
instance instance
locker sync.Mutex
pauseWorker *mgr.WorkerMgr
isPaused bool
isPausedSPN bool
pauseStartTime time.Time
pauseDuration time.Duration
}
type instance interface {
Config() *config.Config
Interception() *interception.Interception
Compat() *compat.Compat
SPNGroup() *mgr.ExtendedGroup
}
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")
}
mgr := mgr.New("Control")
module := &Control{
mgr: mgr,
instance: instance,
}
if err := module.prep(); err != nil {
return nil, err
}
return module, nil
}
func (c *Control) Manager() *mgr.Manager {
return c.mgr
}
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()
}

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

@@ -0,0 +1,192 @@
package control
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/safing/portmaster/base/api"
"github.com/safing/portmaster/base/config"
"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, &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.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()
if duration <= 0 {
return errors.New(logPrefix + "invalid pause duration")
}
if onlySPN {
if c.isPaused {
return errors.New(logPrefix + "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")
}
}
c.stopResumeWorker()
defer func() {
if retErr == nil {
// start new resume worker (with new duration) if no error
c.startResumeWorker(duration)
}
}()
if !c.isPausedSPN {
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() {
config.SetConfigOption("spn/enable", false)
}
// 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 {
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
}
}
c.mgr.Info("interception paused")
c.isPaused = true
return nil
}
func (c *Control) impl_resume() error {
c.locker.Lock()
defer c.locker.Unlock()
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 {
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
}
return nil
}
// 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
if c.pauseWorker != nil {
c.pauseWorker.Stop()
c.pauseWorker = 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.mgr.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)
}