Revert "New updater/installer"

This commit is contained in:
Daniel Hååvi 2024-11-27 16:14:34 +01:00 committed by GitHub
parent 7e5d0b5d7d
commit 952577a431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
190 changed files with 15286 additions and 9096 deletions

View file

@ -1,70 +0,0 @@
name: Release
on:
workflow_dispatch:
jobs:
release-prep:
name: Prep
runs-on: ubuntu-latest
steps:
- uses: earthly/actions-setup@v1
with:
version: v0.8.0
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build all artifacts
run: earthly --remote-cache=ghcr.io/safing/build-cache --push +release-prep
- name: Upload Dist
uses: actions/upload-artifact@v4
with:
name: dist
path: ./dist/
if-no-files-found: error
installer-linux:
name: Installer linux
runs-on: ubuntu-latest
needs: release-prep
steps:
- uses: earthly/actions-setup@v1
with:
version: v0.8.0
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build linux installers
run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +installer-linux
# --ci include --no-output flag
installer-windows:
name: Installer windows
runs-on: windows-latest
needs: release-prep
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Download Dist
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Build windows artifacts
run: powershell -NoProfile -File ./packaging/windows/generate_windows_installers.ps1

View file

@ -34,22 +34,3 @@ jobs:
- name: Build tauri project
run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +tauri-ci
lint:
name: Linter
runs-on: ubuntu-latest
steps:
- uses: earthly/actions-setup@v1
with:
version: v0.8.0
- uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build tauri project
run: earthly --ci --remote-cache=ghcr.io/safing/build-cache --push +tauri-lint

297
Earthfile
View file

@ -3,7 +3,6 @@ VERSION --arg-scope-and-set --global-cache 0.8
ARG --global go_version = 1.22
ARG --global node_version = 18
ARG --global rust_version = 1.79
ARG --global tauri_version = "2.0.1"
ARG --global golangci_lint_version = 1.57.1
ARG --global go_builder_image = "golang:${go_version}-alpine"
@ -57,14 +56,15 @@ build:
# Build Tauri app binaries:
# ./dist/linux_amd64/portmaster-app
# ./dist/windows_amd64/portmaster-app
BUILD +tauri-build --target="x86_64-unknown-linux-gnu"
BUILD +tauri-build --target="x86_64-pc-windows-gnu"
# TODO(vladimir): Build bundles
# ./dist/linux_amd64/Portmaster-0.1.0-1.x86_64.rpm
# ./dist/linux_amd64/Portmaster_0.1.0_amd64.deb
BUILD +tauri-build --target="x86_64-unknown-linux-gnu"
# TODO:
# BUILD +tauri-build --target="x86_64-pc-windows-gnu"
# Bild Tauri bundle for Windows:
# ./dist/windows_amd64/portmaster-app_vX-X-X.zip
BUILD +tauri-build-windows-bundle
# Build UI assets:
# ./dist/all/assets.zip
@ -82,7 +82,7 @@ angular-ci:
tauri-ci:
BUILD +tauri-build --target="x86_64-unknown-linux-gnu"
BUILD +tauri-build --target="x86_64-pc-windows-gnu"
BUILD +tauri-build-windows-bundle
kext-ci:
BUILD +kext-build
@ -177,7 +177,7 @@ go-build:
ARG GOOS=linux
ARG GOARCH=amd64
ARG GOARM
ARG CMDS=portmaster-core
ARG CMDS=portmaster-start portmaster-core
CACHE --sharing shared "$GOCACHE"
CACHE --sharing shared "$GOMODCACHE"
@ -349,7 +349,6 @@ angular-project:
# Save portmaster UI as local artifact.
IF [ "${project}" = "portmaster" ]
SAVE ARTIFACT --keep-ts "./${project}.zip" AS LOCAL ${outputDir}/all/${project}-ui.zip
SAVE ARTIFACT --keep-ts "./${project}.zip" output/${project}.zip
END
# Build the angular projects (portmaster-UI and tauri-builtin) in dev mode
@ -421,7 +420,7 @@ rust-base:
DO rust+INIT --keep_fingerprints=true
# For now we need tauri-cli 2.0.0 for bulding
DO rust+CARGO --args="install tauri-cli --version ${tauri_version} --locked"
DO rust+CARGO --args="install tauri-cli --version ^2.0.0-beta"
# Explicitly cache here.
SAVE IMAGE --cache-hint
@ -435,7 +434,7 @@ tauri-src:
# are preserved such that Rust's incremental compilation works correctly.
COPY --keep-ts ./desktop/tauri/ .
COPY assets/data ./../../assets/data
COPY packaging ./../../packaging
COPY packaging/linux ./../../packaging/linux
COPY (+angular-project/output/tauri-builtin --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref="/") ./../angular/dist/tauri-builtin
WORKDIR /app/tauri/src-tauri
@ -447,14 +446,48 @@ tauri-build:
FROM +tauri-src
ARG --required target
ARG output=".*/release/(([^\./]+|([^\./]+\.(dll|exe)))|bundle/(deb|rpm)/.*\.(deb|rpm))"
ARG bundle="none"
# if we want tauri to create the installer bundles we also need to provide all external binaries
# we need to do some magic here because tauri expects the binaries to include the rust target tripple.
# We already know that triple because it's a required argument. From that triple, we use +RUST_TO_GO_ARCH_STRING
# function from below to parse the triple and guess wich GOOS and GOARCH we need.
RUN mkdir /tmp/gobuild
RUN mkdir ./binaries
ARG output=".*/release/([^\./]+|([^\./]+\.(dll|exe)))"
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}"
RUN echo "GOOS=${GOOS} GOARCH=${GOARCH} GOARM=${GOARM} GO_ARCH_STRING=${GO_ARCH_STRING}"
# Our tauri app has externalBins configured so tauri will try to embed them when it finished compiling
# the app. Make sure we copy portmaster-start and portmaster-core in all architectures supported.
# See documentation for externalBins for more information on how tauri searches for the binaries.
COPY (+go-build/output --CMDS="portmaster-start portmaster-core" --GOOS="${GOOS}" --GOARCH="${GOARCH}" --GOARM="${GOARM}") /tmp/gobuild
# Place them in the correct folder with the rust target tripple attached.
FOR bin IN $(ls /tmp/gobuild)
# ${bin$.*} does not work in SET commands unfortunately so we use a shell
# snippet here:
RUN set -e ; \
dest="./binaries/${bin}-${target}" ; \
if [ -z "${bin##*.exe}" ]; then \
dest="./binaries/${bin%.*}-${target}.exe" ; \
fi ; \
cp "/tmp/gobuild/${bin}" "${dest}" ;
END
# Just for debugging ...
# RUN ls -R ./binaries
# The following is exected to work but doesn't. for whatever reason cargo-sweep errors out on the windows-toolchain.
#
# DO rust+CARGO --args="tauri build --bundles none --ci --target=${target}" --output="release/[^/\.]+"
#
# For, now, we just directly mount the rust target cache and call cargo ourself.
DO rust+SET_CACHE_MOUNTS_ENV
RUN rustup target add "${target}"
RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo tauri build --ci --target="${target}" --no-bundle
RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo tauri build --ci --target="${target}"
DO rust+COPY_OUTPUT --output="${output}"
# BUG(cross-compilation):
@ -473,13 +506,127 @@ tauri-build:
RUN echo output: $(ls -R "target/${target}/release")
# Binaries
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/portmaster" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/portmaster"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/portmaster.exe" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/portmaster.exe"
# SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/WebView2Loader.dll" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/WebView2Loader.dll"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/app" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/portmaster-app"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/app.exe" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/portmaster-app.exe"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/WebView2Loader.dll" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/WebView2Loader.dll"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/portmaster" ./output/portmaster
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/portmaster.exe" ./output/portmaster.exe
# Installers
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/deb/*.deb" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/rpm/*.rpm" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
tauri-build-windows-bundle:
FROM +tauri-src
ARG target="x86_64-pc-windows-gnu"
ARG output=".*/release/(([^\./]+|([^\./]+\.(dll|exe))))"
ARG bundle="none"
ARG GOOS=windows
ARG GOARCH=amd64
ARG GOARM
# The binaries will not be used but we still need to create them. Tauri will check for them.
RUN mkdir /tmp/gobuild
RUN mkdir ./binaries
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}"
RUN echo "GOOS=${GOOS} GOARCH=${GOARCH} GOARM=${GOARM} GO_ARCH_STRING=${GO_ARCH_STRING}"
# Our tauri app has externalBins configured so tauri will look for them when it finished compiling
# the app. Make sure we copy portmaster-start and portmaster-core in all architectures supported.
# See documentation for externalBins for more information on how tauri searches for the binaries.
COPY (+go-build/output --GOOS="${GOOS}" --CMDS="portmaster-start portmaster-core" --GOARCH="${GOARCH}" --GOARM="${GOARM}") /tmp/gobuild
# Place them in the correct folder with the rust target tripple attached.
FOR bin IN $(ls /tmp/gobuild)
# ${bin$.*} does not work in SET commands unfortunately so we use a shell
# snippet here:
RUN set -e ; \
dest="./binaries/${bin}-${target}" ; \
if [ -z "${bin##*.exe}" ]; then \
dest="./binaries/${bin%.*}-${target}.exe" ; \
fi ; \
cp "/tmp/gobuild/${bin}" "${dest}" ;
END
# Just for debugging ...
# RUN ls -R ./binaries
DO rust+SET_CACHE_MOUNTS_ENV
RUN rustup target add "${target}"
RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo tauri build --no-bundle --ci --target="${target}"
DO rust+COPY_OUTPUT --output="${output}"
# Get version from git.
COPY .git .
LET version = "$(git tag --points-at || true)"
IF [ -z "${version}" ]
LET dev_version = "$(git describe --tags --first-parent --abbrev=0 || true)"
IF [ -n "${dev_version}" ]
SET version = "${dev_version}"
END
END
IF [ -z "${version}" ]
SET version = "v0.0.0"
END
ENV VERSION="${version}"
RUN echo "Version: $VERSION"
ENV VERSION_SUFFIX="$(echo $VERSION | tr '.' '-')"
RUN echo "Version Suffix: $VERSION_SUFFIX"
RUN echo output: $(ls -R "target/${target}/release")
RUN mv "target/${target}/release/app.exe" "target/${target}/release/portmaster-app_${VERSION_SUFFIX}.exe"
RUN zip "target/${target}/release/portmaster-app_${VERSION_SUFFIX}.zip" "target/${target}/release/portmaster-app_${VERSION_SUFFIX}.exe" -j portmaster-app${VERSION_SUFFIX}.exe "target/${target}/release/WebView2Loader.dll" -j WebView2Loader.dll
SAVE ARTIFACT --if-exists "target/${target}/release/portmaster-app_${VERSION_SUFFIX}.zip" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
tauri-prep-windows:
FROM +angular-base --configuration=production
ARG target="x86_64-pc-windows-msvc"
# if we want tauri to create the installer bundles we also need to provide all external binaries
# we need to do some magic here because tauri expects the binaries to include the rust target tripple.
# We already know that triple because it's a required argument. From that triple, we use +RUST_TO_GO_ARCH_STRING
# function from below to parse the triple and guess wich GOOS and GOARCH we need.
RUN mkdir /tmp/gobuild
RUN mkdir ./binaries
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}"
RUN echo "GOOS=${GOOS} GOARCH=${GOARCH} GOARM=${GOARM} GO_ARCH_STRING=${GO_ARCH_STRING}"
# Our tauri app has externalBins configured so tauri will try to embed them when it finished compiling
# the app. Make sure we copy portmaster-start and portmaster-core in all architectures supported.
# See documentation for externalBins for more information on how tauri searches for the binaries.
COPY (+go-build/output --GOOS="${GOOS}" --CMDS="portmaster-start portmaster-core" --GOARCH="${GOARCH}" --GOARM="${GOARM}") /tmp/gobuild
# Place them in the correct folder with the rust target tripple attached.
FOR bin IN $(ls /tmp/gobuild)
# ${bin$.*} does not work in SET commands unfortunately so we use a shell
# snippet here:
RUN set -e ; \
dest="./binaries/${bin}-${target}" ; \
if [ -z "${bin##*.exe}" ]; then \
dest="./binaries/${bin%.*}-${target}.exe" ; \
fi ; \
cp "/tmp/gobuild/${bin}" "${dest}" ;
END
# Copy source
COPY --keep-ts ./desktop/tauri/src-tauri src-tauri
COPY --keep-ts ./assets assets
# Build UI
ENV NODE_ENV="production"
RUN --no-cache ./node_modules/.bin/ng build --configuration production --base-href / "tauri-builtin"
# Just for debugging ...
# RUN ls -R ./binaries
# RUN ls -R ./dist
SAVE ARTIFACT "./dist/tauri-builtin" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/desktop/angular/dist/"
SAVE ARTIFACT "./src-tauri" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/desktop/tauri/src-tauri"
SAVE ARTIFACT "./binaries" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/desktop/tauri/src-tauri/"
SAVE ARTIFACT "./assets" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/assets"
tauri-release:
FROM ${work_image}
@ -489,120 +636,6 @@ tauri-release:
BUILD +tauri-build --target="${arch}"
END
tauri-lint:
FROM +rust-base
ARG target="x86_64-unknown-linux-gnu"
WORKDIR /app
# Copy static files that are embedded inside the executable.
COPY --keep-ts ./assets ./assets
# Copy all the rust code
COPY --keep-ts ./desktop/tauri ./desktop/tauri
# Create a empty ui dir so it will satisfy the build.
RUN mkdir -p ./desktop/angular/dist/tauri-builtin
SAVE IMAGE --cache-hint
# Run the linter.
WORKDIR /app/desktop/tauri/src-tauri
RUN --mount=$EARTHLY_RUST_TARGET_CACHE cargo clippy --all-targets --all-features -- -D warnings
release-prep:
FROM +rust-base
# Linux specific
COPY (+tauri-build/output/portmaster --target="x86_64-unknown-linux-gnu") ./output/binary/linux_amd64/portmaster
COPY (+go-build/output/portmaster-core --GOARCH=amd64 --GOOS=linux --CMDS=portmaster-core) ./output/binary/linux_amd64/portmaster-core
# Windows specific
COPY (+tauri-build/output/portmaster.exe --target="x86_64-pc-windows-gnu") ./output/binary/windows_amd64/portmaster.exe
COPY (+go-build/output/portmaster-core.exe --GOARCH=amd64 --GOOS=windows --CMDS=portmaster-core) ./output/binary/windows_amd64/portmaster-core.exe
# TODO(vladimir): figure out a way to get the lastest release of the kext.
RUN touch ./output/binary/windows_amd64/portmaster-kext.sys
# All platforms
COPY (+assets/assets.zip) ./output/binary/all/assets.zip
COPY (+angular-project/output/portmaster.zip --project=portmaster --dist=./dist --configuration=production --baseHref=/ui/modules/portmaster/) ./output/binary/all/portmaster.zip
# Intel
# TODO(vladimir): figure out a way to download all latest intel data.
RUN mkdir -p ./output/intel
RUN wget -O ./output/intel/geoipv4.mmdb.gz "https://updates.safing.io/all/intel/geoip/geoipv4_v20240820-0-1.mmdb.gz" && \
wget -O ./output/intel/geoipv6.mmdb.gz "https://updates.safing.io/all/intel/geoip/geoipv6_v20240820-0-1.mmdb.gz" && \
wget -O ./output/intel/index.dsd "https://updates.safing.io/all/intel/lists/index_v2023-6-13.dsd" && \
wget -O ./output/intel/base.dsdl "https://updates.safing.io/all/intel/lists/base_v20241001-0-9.dsdl" && \
wget -O ./output/intel/intermediate.dsdl "https://updates.safing.io/all/intel/lists/intermediate_v20240929-0-0.dsdl" && \
wget -O ./output/intel/urgent.dsdl "https://updates.safing.io/all/intel/lists/urgent_v20241002-2-14.dsdl"
COPY (+go-build/output/updatemgr --GOARCH=amd64 --GOOS=linux --CMDS=updatemgr) ./updatemgr
RUN ./updatemgr scan --dir "./output/binary" > ./output/binary/index.json
RUN ./updatemgr scan --dir "./output/intel" > ./output/intel/index.json
# Intel Extracted (needed for the installers)
RUN mkdir -p ./output/intel_decompressed
RUN cp ./output/intel/index.json ./output/intel_decompressed/index.json
RUN gzip -dc ./output/intel/geoipv4.mmdb.gz > ./output/intel_decompressed/geoipv4.mmdb
RUN gzip -dc ./output/intel/geoipv6.mmdb.gz > ./output/intel_decompressed/geoipv6.mmdb
RUN cp ./output/intel/index.dsd ./output/intel_decompressed/index.dsd
RUN cp ./output/intel/base.dsdl ./output/intel_decompressed/base.dsdl
RUN cp ./output/intel/intermediate.dsdl ./output/intel_decompressed/intermediate.dsdl
RUN cp ./output/intel/urgent.dsdl ./output/intel_decompressed/urgent.dsdl
# Save all artifacts to output folder
SAVE ARTIFACT --if-exists --keep-ts "output/binary/index.json" AS LOCAL "${outputDir}/binary/index.json"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/all/*" AS LOCAL "${outputDir}/binary/all/"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/linux_amd64/*" AS LOCAL "${outputDir}/binary/linux_amd64/"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/windows_amd64/*" AS LOCAL "${outputDir}/binary/windows_amd64/"
SAVE ARTIFACT --if-exists --keep-ts "output/intel/*" AS LOCAL "${outputDir}/intel/"
SAVE ARTIFACT --if-exists --keep-ts "output/intel_decompressed/*" AS LOCAL "${outputDir}/intel_decompressed/"
# Save all artifacts to the container output folder so other containers can access it.
SAVE ARTIFACT --if-exists --keep-ts "output/binary/index.json" "output/binary/index.json"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/all/*" "output/binary/all/"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/linux_amd64/*" "output/binary/linux_amd64/"
SAVE ARTIFACT --if-exists --keep-ts "output/binary/windows_amd64/*" "output/binary/windows_amd64/"
SAVE ARTIFACT --if-exists --keep-ts "output/intel/*" "output/intel/"
SAVE ARTIFACT --if-exists --keep-ts "output/intel_decompressed/*" "output/intel_decompressed/"
installer-linux:
FROM +rust-base
# ARG --required target
ARG target="x86_64-unknown-linux-gnu"
WORKDIR /app/tauri
COPY --keep-ts ./desktop/tauri/ .
COPY assets/data ./../../assets/data
COPY packaging ./../../packaging
WORKDIR /app/tauri/src-tauri
SAVE IMAGE --cache-hint
DO +RUST_TO_GO_ARCH_STRING --rustTarget="${target}"
# Build and copy the binaries
RUN mkdir -p target/${target}/release
COPY (+release-prep/output/binary/linux_amd64/portmaster) ./target/${target}/release/portmaster
RUN mkdir -p binary
COPY (+release-prep/output/binary/index.json) ./binary/index.json
COPY (+release-prep/output/binary/linux_amd64/portmaster-core) ./binary/portmaster-core
COPY (+release-prep/output/binary/all/portmaster.zip) ./binary/portmaster.zip
COPY (+release-prep/output/binary/all/assets.zip) ./binary/assets.zip
# Download the intel data
RUN mkdir -p intel
COPY (+release-prep/output/intel_decompressed/*) ./intel/
# build the installers
RUN cargo tauri bundle --ci --target="${target}"
# Installers
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/deb/*.deb" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
SAVE ARTIFACT --if-exists --keep-ts "target/${target}/release/bundle/rpm/*.rpm" AS LOCAL "${outputDir}/${GO_ARCH_STRING}/"
kext-build:
FROM ${rust_builder_image}

View file

@ -12,7 +12,6 @@ import (
"time"
"github.com/safing/portmaster/base/info"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils/debug"
)
@ -153,12 +152,12 @@ func getStack(_ *Request) (data []byte, err error) {
// printStack prints the current goroutine stack to stderr.
func printStack(_ *Request) (msg string, err error) {
_, err = fmt.Fprint(log.GlobalWriter, "===== PRINTING STACK =====\n")
_, err = fmt.Fprint(os.Stderr, "===== PRINTING STACK =====\n")
if err == nil {
err = pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
}
if err == nil {
_, err = fmt.Fprint(log.GlobalWriter, "===== END OF STACK =====\n")
_, err = fmt.Fprint(os.Stderr, "===== END OF STACK =====\n")
}
if err != nil {
return "", err

View file

@ -6,52 +6,30 @@ import (
"testing"
)
type testInstance struct {
dataDir string
}
type testInstance struct{}
var _ instance = testInstance{}
func (stub testInstance) DataDir() string {
return stub.dataDir
}
func (stub testInstance) SetCmdLineOperation(f func() error) {}
func newTestInstance(testName string) (*testInstance, error) {
testDir, err := os.MkdirTemp("", fmt.Sprintf("portmaster-%s", testName))
func runTest(m *testing.M) error {
ds, err := InitializeUnitTestDataroot("test-config")
if err != nil {
return nil, fmt.Errorf("failed to make tmp dir: %w", err)
return fmt.Errorf("failed to initialize dataroot: %w", err)
}
return &testInstance{
dataDir: testDir,
}, nil
}
func TestMain(m *testing.M) {
instance, err := newTestInstance("test-config")
defer func() { _ = os.RemoveAll(ds) }()
module, err = New(&testInstance{})
if err != nil {
panic(fmt.Errorf("failed to create test instance: %w", err))
}
defer func() { _ = os.RemoveAll(instance.DataDir()) }()
module, err = New(instance)
if err != nil {
panic(fmt.Errorf("failed to initialize module: %w", err))
return fmt.Errorf("failed to initialize module: %w", err)
}
m.Run()
return nil
}
func TestConfigPersistence(t *testing.T) { //nolint:paralleltest
err := SaveConfig()
if err != nil {
t.Fatal(err)
}
err = loadConfig(true)
if err != nil {
t.Fatal(err)
func TestMain(m *testing.M) {
if err := runTest(m); err != nil {
fmt.Printf("%s\n", err)
os.Exit(1)
}
}

View file

@ -10,6 +10,8 @@ import (
"path/filepath"
"sort"
"github.com/safing/portmaster/base/dataroot"
"github.com/safing/portmaster/base/utils"
"github.com/safing/portmaster/base/utils/debug"
"github.com/safing/portmaster/service/mgr"
)
@ -17,13 +19,29 @@ import (
// ChangeEvent is the name of the config change event.
const ChangeEvent = "config change"
var exportConfig bool
var (
dataRoot *utils.DirStructure
exportConfig bool
)
// SetDataRoot sets the data root from which the updates module derives its paths.
func SetDataRoot(root *utils.DirStructure) {
if dataRoot == nil {
dataRoot = root
}
}
func init() {
flag.BoolVar(&exportConfig, "export-config-options", false, "export configuration registry and exit")
}
func prep() error {
SetDataRoot(dataroot.Root())
if dataRoot == nil {
return errors.New("data root is not set")
}
if exportConfig {
module.instance.SetCmdLineOperation(exportConfigCmd)
return mgr.ErrExecuteCmdLineOp
@ -33,7 +51,7 @@ func prep() error {
}
func start() error {
configFilePath = filepath.Join(module.instance.DataDir(), "config.json")
configFilePath = filepath.Join(dataRoot.Path, "config.json")
// Load log level from log package after it started.
err := loadLogLevel()
@ -118,3 +136,20 @@ func GetActiveConfigValues() map[string]interface{} {
return values
}
// InitializeUnitTestDataroot initializes a new random tmp directory for running tests.
func InitializeUnitTestDataroot(testName string) (string, error) {
basePath, err := os.MkdirTemp("", fmt.Sprintf("portmaster-%s", testName))
if err != nil {
return "", fmt.Errorf("failed to make tmp dir: %w", err)
}
ds := utils.NewDirStructure(basePath, 0o0755)
SetDataRoot(ds)
err = dataroot.Initialize(basePath, 0o0755)
if err != nil {
return "", fmt.Errorf("failed to initialize dataroot: %w", err)
}
return basePath, nil
}

View file

@ -56,6 +56,5 @@ func New(instance instance) (*Config, error) {
}
type instance interface {
DataDir() string
SetCmdLineOperation(f func() error)
}

View file

@ -26,7 +26,7 @@ func TestMain(m *testing.M) {
panic(err)
}
err = Initialize(testDir)
err = InitializeWithPath(testDir)
if err != nil {
panic(err)
}

View file

@ -2,10 +2,11 @@ package dbmodule
import (
"errors"
"path/filepath"
"sync/atomic"
"github.com/safing/portmaster/base/database"
"github.com/safing/portmaster/base/dataroot"
"github.com/safing/portmaster/base/utils"
"github.com/safing/portmaster/service/mgr"
)
@ -26,18 +27,18 @@ func (dbm *DBModule) Stop() error {
return stop()
}
var databasesRootDir string
var databaseStructureRoot *utils.DirStructure
// SetDatabaseLocation sets the location of the database for initialization. Supply either a path or dir structure.
func SetDatabaseLocation(dir string) {
if databasesRootDir == "" {
databasesRootDir = dir
func SetDatabaseLocation(dirStructureRoot *utils.DirStructure) {
if databaseStructureRoot == nil {
databaseStructureRoot = dirStructureRoot
}
}
func prep() error {
SetDatabaseLocation(filepath.Join(module.instance.DataDir(), "databases"))
if databasesRootDir == "" {
SetDatabaseLocation(dataroot.Root())
if databaseStructureRoot == nil {
return errors.New("database location not specified")
}
@ -63,16 +64,16 @@ func New(instance instance) (*DBModule, error) {
return nil, errors.New("only one instance allowed")
}
if err := prep(); err != nil {
return nil, err
}
m := mgr.New("DBModule")
module = &DBModule{
mgr: m,
instance: instance,
}
if err := prep(); err != nil {
return nil, err
}
err := database.Initialize(databasesRootDir)
err := database.Initialize(databaseStructureRoot)
if err != nil {
return nil, err
}
@ -80,6 +81,4 @@ func New(instance instance) (*DBModule, error) {
return module, nil
}
type instance interface {
DataDir() string
}
type instance interface{}

View file

@ -3,10 +3,14 @@ package database
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/tevino/abool"
"github.com/safing/portmaster/base/utils"
)
const (
databasesSubDir = "databases"
)
var (
@ -15,18 +19,25 @@ var (
shuttingDown = abool.NewBool(false)
shutdownSignal = make(chan struct{})
rootDir string
rootStructure *utils.DirStructure
databasesStructure *utils.DirStructure
)
// Initialize initializes the database at the specified location.
func Initialize(databasesRootDir string) error {
if initialized.SetToIf(false, true) {
rootDir = databasesRootDir
// InitializeWithPath initializes the database at the specified location using a path.
func InitializeWithPath(dirPath string) error {
return Initialize(utils.NewDirStructure(dirPath, 0o0755))
}
// Ensure database root dir exists.
err := os.MkdirAll(rootDir, 0o0700)
// Initialize initializes the database at the specified location using a dir structure.
func Initialize(dirStructureRoot *utils.DirStructure) error {
if initialized.SetToIf(false, true) {
rootStructure = dirStructureRoot
// ensure root and databases dirs
databasesStructure = rootStructure.ChildDir(databasesSubDir, 0o0700)
err := databasesStructure.Ensure()
if err != nil {
return fmt.Errorf("could not create/open database directory (%s): %w", rootDir, err)
return fmt.Errorf("could not create/open database directory (%s): %w", rootStructure.Path, err)
}
return nil
@ -56,12 +67,11 @@ func Shutdown() (err error) {
// getLocation returns the storage location for the given name and type.
func getLocation(name, storageType string) (string, error) {
location := filepath.Join(rootDir, name, storageType)
// Make sure location exists.
err := os.MkdirAll(location, 0o0700)
location := databasesStructure.ChildDir(name, 0o0700).ChildDir(storageType, 0o0700)
// check location
err := location.Ensure()
if err != nil {
return "", fmt.Errorf("failed to create/check database dir %q: %w", location, err)
return "", fmt.Errorf(`failed to create/check database dir "%s": %w`, location.Path, err)
}
return location, nil
return location.Path, nil
}

25
base/dataroot/root.go Normal file
View file

@ -0,0 +1,25 @@
package dataroot
import (
"errors"
"os"
"github.com/safing/portmaster/base/utils"
)
var root *utils.DirStructure
// Initialize initializes the data root directory.
func Initialize(rootDir string, perm os.FileMode) error {
if root != nil {
return errors.New("already initialized")
}
root = utils.NewDirStructure(rootDir, perm)
return root.Ensure()
}
// Root returns the data root directory.
func Root() *utils.DirStructure {
return root
}

View file

@ -10,8 +10,6 @@ import (
"sync"
)
// FIXME: version does not show in portmaster
var (
name string
license string
@ -153,28 +151,6 @@ func FullVersion() string {
return builder.String()
}
// CondensedVersion returns the rather complete, but condensed version string.
func CondensedVersion() string {
info := GetInfo()
cgoInfo := "-cgo"
if info.CGO {
cgoInfo = "+cgo"
}
dirtyInfo := "clean"
if info.Dirty {
dirtyInfo = "dirty"
}
return fmt.Sprintf(
"%s %s (%s; built with %s [%s %s] from %s [%s] at %s)",
info.Name, version,
runtime.GOOS,
runtime.Version(), runtime.Compiler, cgoInfo,
info.Commit, dirtyInfo, info.CommitTime,
)
}
// CheckVersion checks if the metadata is ok.
func CheckVersion() error {
switch {

13
base/log/flags.go Normal file
View file

@ -0,0 +1,13 @@
package log
import "flag"
var (
logLevelFlag string
pkgLogLevelsFlag string
)
func init() {
flag.StringVar(&logLevelFlag, "log", "", "set log level to [trace|debug|info|warning|error|critical]")
flag.StringVar(&pkgLogLevelsFlag, "plog", "", "set log level of packages: database=trace,notifications=debug")
}

View file

@ -3,6 +3,7 @@ package log
import (
"fmt"
"runtime"
"strings"
"sync/atomic"
"time"
)
@ -24,11 +25,6 @@ func log(level Severity, msg string, tracer *ContextTracer) {
return
}
// Check log level.
if uint32(level) < atomic.LoadUint32(logLevel) {
return
}
// get time
now := time.Now()
@ -45,6 +41,31 @@ func log(level Severity, msg string, tracer *ContextTracer) {
}
}
// check if level is enabled for file or generally
if pkgLevelsActive.IsSet() {
pathSegments := strings.Split(file, "/")
if len(pathSegments) < 2 {
// file too short for package levels
return
}
pkgLevelsLock.Lock()
severity, ok := pkgLevels[pathSegments[len(pathSegments)-2]]
pkgLevelsLock.Unlock()
if ok {
if level < severity {
return
}
} else {
// no package level set, check against global level
if uint32(level) < atomic.LoadUint32(logLevel) {
return
}
}
} else if uint32(level) < atomic.LoadUint32(logLevel) {
// no package levels set, check against global level
return
}
// create log object
log := &logLine{
msg: msg,
@ -80,7 +101,13 @@ func log(level Severity, msg string, tracer *ContextTracer) {
}
func fastcheck(level Severity) bool {
return uint32(level) >= atomic.LoadUint32(logLevel)
if pkgLevelsActive.IsSet() {
return true
}
if uint32(level) >= atomic.LoadUint32(logLevel) {
return true
}
return false
}
// Trace is used to log tiny steps. Log traces to context if you can!

View file

@ -2,7 +2,6 @@ package log
import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
@ -34,26 +33,6 @@ import (
// Severity describes a log level.
type Severity uint32
func (s Severity) toSLogLevel() slog.Level {
// Convert to slog level.
switch s {
case TraceLevel:
return slog.LevelDebug
case DebugLevel:
return slog.LevelDebug
case InfoLevel:
return slog.LevelInfo
case WarningLevel:
return slog.LevelWarn
case ErrorLevel:
return slog.LevelError
case CriticalLevel:
return slog.LevelError
}
// Failed to convert, return default log level
return slog.LevelWarn
}
// Message describes a log level message and is implemented
// by logLine.
type Message interface {
@ -126,6 +105,10 @@ var (
logLevelInt = uint32(InfoLevel)
logLevel = &logLevelInt
pkgLevelsActive = abool.NewBool(false)
pkgLevels = make(map[string]Severity)
pkgLevelsLock sync.Mutex
logsWaiting = make(chan struct{}, 1)
logsWaitingFlag = abool.NewBool(false)
@ -138,6 +121,19 @@ var (
startedSignal = make(chan struct{})
)
// SetPkgLevels sets individual log levels for packages. Only effective after Start().
func SetPkgLevels(levels map[string]Severity) {
pkgLevelsLock.Lock()
pkgLevels = levels
pkgLevelsLock.Unlock()
pkgLevelsActive.Set()
}
// UnSetPkgLevels removes all individual log levels for packages.
func UnSetPkgLevels() {
pkgLevelsActive.UnSet()
}
// GetLogLevel returns the current log level.
func GetLogLevel() Severity {
return Severity(atomic.LoadUint32(logLevel))
@ -191,36 +187,47 @@ func ParseLevel(level string) Severity {
}
// Start starts the logging system. Must be called in order to see logs.
func Start(level string, logToStdout bool, logDir string) (err error) {
func Start() (err error) {
if !initializing.SetToIf(false, true) {
return nil
}
// Parse log level argument.
initialLogLevel := InfoLevel
if level != "" {
initialLogLevel = ParseLevel(level)
logBuffer = make(chan *logLine, 1024)
if logLevelFlag != "" {
initialLogLevel := ParseLevel(logLevelFlag)
if initialLogLevel == 0 {
fmt.Fprintf(os.Stderr, "log warning: invalid log level %q, falling back to level info\n", level)
fmt.Fprintf(os.Stderr, "log warning: invalid log level \"%s\", falling back to level info\n", logLevelFlag)
initialLogLevel = InfoLevel
}
}
// Setup writer.
if logToStdout {
GlobalWriter = NewStdoutWriter()
SetLogLevel(initialLogLevel)
} else {
// Create file log writer.
var err error
GlobalWriter, err = NewFileWriter(logDir)
if err != nil {
return fmt.Errorf("failed to initialize log file: %w", err)
}
// Setup slog here for the transition period.
setupSLog(GetLogLevel())
}
// Init logging systems.
SetLogLevel(initialLogLevel)
logBuffer = make(chan *logLine, 1024)
// get and set file loglevels
pkgLogLevels := pkgLogLevelsFlag
if len(pkgLogLevels) > 0 {
newPkgLevels := make(map[string]Severity)
for _, pair := range strings.Split(pkgLogLevels, ",") {
splitted := strings.Split(pair, "=")
if len(splitted) != 2 {
err = fmt.Errorf("log warning: invalid file log level \"%s\", ignoring", pair)
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
break
}
fileLevel := ParseLevel(splitted[1])
if fileLevel == 0 {
err = fmt.Errorf("log warning: invalid file log level \"%s\", ignoring", pair)
fmt.Fprintf(os.Stderr, "%s\n", err.Error())
break
}
newPkgLevels[splitted[0]] = fileLevel
}
SetPkgLevels(newPkgLevels)
}
if !schedulingEnabled {
close(writeTrigger)
@ -230,14 +237,6 @@ func Start(level string, logToStdout bool, logDir string) (err error) {
started.Set()
close(startedSignal)
// Delete all logs older than one month.
if !logToStdout {
err = CleanOldLogs(logDir, 30*24*time.Hour)
if err != nil {
Errorf("log: failed to clean old log files: %s", err)
}
}
return err
}
@ -247,5 +246,4 @@ func Shutdown() {
close(shutdownSignal)
}
shutdownWaitGroup.Wait()
GlobalWriter.Close()
}

View file

@ -7,7 +7,7 @@ import (
)
func init() {
err := Start("info", true, "")
err := Start()
if err != nil {
panic(fmt.Sprintf("start failed: %s", err))
}
@ -56,6 +56,9 @@ func TestLogging(t *testing.T) {
// wait logs to be written
time.Sleep(1 * time.Millisecond)
// just for show
UnSetPkgLevels()
// do not really shut down, we may need logging for other tests
// ShutdownLogging()
}

View file

@ -2,25 +2,78 @@ package log
import (
"fmt"
"os"
"runtime/debug"
"sync"
"time"
"github.com/safing/portmaster/base/info"
)
// Adapter is used to write logs.
type Adapter interface {
// Write is called for each log message.
WriteMessage(msg Message, duplicates uint64)
}
type (
// Adapter is used to write logs.
Adapter interface {
// Write is called for each log message.
Write(msg Message, duplicates uint64)
}
// AdapterFunc is a convenience type for implementing
// Adapter.
AdapterFunc func(msg Message, duplicates uint64)
// FormatFunc formats msg into a string.
FormatFunc func(msg Message, duplicates uint64) string
// SimpleFileAdapter implements Adapter and writes all
// messages to File.
SimpleFileAdapter struct {
Format FormatFunc
File *os.File
}
)
var (
// StdoutAdapter is a simple file adapter that writes
// all logs to os.Stdout using a predefined format.
StdoutAdapter = &SimpleFileAdapter{
File: os.Stdout,
Format: defaultColorFormater,
}
// StderrAdapter is a simple file adapter that writes
// all logs to os.Stdout using a predefined format.
StderrAdapter = &SimpleFileAdapter{
File: os.Stderr,
Format: defaultColorFormater,
}
)
var (
adapter Adapter = StdoutAdapter
schedulingEnabled = false
writeTrigger = make(chan struct{})
)
// EnableScheduling enables external scheduling of the logger. This will require to manually trigger writes via TriggerWrite whenever logs should be written. Please note that full buffers will also trigger writing. Must be called before Start() to have an effect.
// SetAdapter configures the logging adapter to use.
// This must be called before the log package is initialized.
func SetAdapter(a Adapter) {
if initializing.IsSet() || a == nil {
return
}
adapter = a
}
// Write implements Adapter and calls fn.
func (fn AdapterFunc) Write(msg Message, duplicates uint64) {
fn(msg, duplicates)
}
// Write implements Adapter and writes msg the underlying file.
func (fileAdapter *SimpleFileAdapter) Write(msg Message, duplicates uint64) {
fmt.Fprintln(fileAdapter.File, fileAdapter.Format(msg, duplicates))
}
// EnableScheduling enables external scheduling of the logger. This will require to manually trigger writes via TriggerWrite whenevery logs should be written. Please note that full buffers will also trigger writing. Must be called before Start() to have an effect.
func EnableScheduling() {
if !initializing.IsSet() {
schedulingEnabled = true
@ -42,47 +95,27 @@ func TriggerWriterChannel() chan struct{} {
return writeTrigger
}
func defaultColorFormater(line Message, duplicates uint64) string {
return formatLine(line.(*logLine), duplicates, true) //nolint:forcetypeassert // TODO: improve
}
func startWriter() {
if GlobalWriter.isStdout {
fmt.Fprintf(GlobalWriter,
"%s%s%s %sBOF %s%s\n",
fmt.Printf(
"%s%s%s %sBOF %s%s\n",
dimColor(),
time.Now().Format(timeFormat),
endDimColor(),
dimColor(),
time.Now().Format(timeFormat),
endDimColor(),
blueColor(),
rightArrow,
endColor(),
)
} else {
fmt.Fprintf(GlobalWriter,
"%s BOF %s\n",
time.Now().Format(timeFormat),
rightArrow,
)
}
writeVersion()
blueColor(),
rightArrow,
endColor(),
)
shutdownWaitGroup.Add(1)
go writerManager()
}
func writeVersion() {
if GlobalWriter.isStdout {
fmt.Fprintf(GlobalWriter, "%s%s%s running %s%s%s\n",
dimColor(),
time.Now().Format(timeFormat),
endDimColor(),
blueColor(),
info.CondensedVersion(),
endColor())
} else {
fmt.Fprintf(GlobalWriter, "%s running %s\n", time.Now().Format(timeFormat), info.CondensedVersion())
}
}
func writerManager() {
defer shutdownWaitGroup.Done()
@ -96,17 +129,18 @@ func writerManager() {
}
}
func writer() error {
var err error
// defer should be able to edit the err. So naked return is required.
// nolint:golint,nakedret
func writer() (err error) {
defer func() {
// recover from panic
panicVal := recover()
if panicVal != nil {
_, err = fmt.Fprintf(GlobalWriter, "%s", panicVal)
err = fmt.Errorf("%s", panicVal)
// write stack to stderr
fmt.Fprintf(
GlobalWriter,
os.Stderr,
`===== Error Report =====
Message: %s
StackTrace:
@ -135,7 +169,7 @@ StackTrace:
case <-forceEmptyingOfBuffer: // log buffer is full!
case <-shutdownSignal: // shutting down
finalizeWriting()
return err
return
}
// wait for timeslot to log
@ -144,7 +178,7 @@ StackTrace:
case <-forceEmptyingOfBuffer: // log buffer is full!
case <-shutdownSignal: // shutting down
finalizeWriting()
return err
return
}
// write all the logs!
@ -167,7 +201,7 @@ StackTrace:
}
// if currentLine and line are _not_ equal, output currentLine
GlobalWriter.WriteMessage(currentLine, duplicates)
adapter.Write(currentLine, duplicates)
// add to unexpected logs
addUnexpectedLogs(currentLine)
// reset duplicate counter
@ -181,7 +215,7 @@ StackTrace:
// write final line
if currentLine != nil {
GlobalWriter.WriteMessage(currentLine, duplicates)
adapter.Write(currentLine, duplicates)
// add to unexpected logs
addUnexpectedLogs(currentLine)
}
@ -191,7 +225,7 @@ StackTrace:
case <-time.After(10 * time.Millisecond):
case <-shutdownSignal:
finalizeWriting()
return err
return
}
}
@ -201,27 +235,19 @@ func finalizeWriting() {
for {
select {
case line := <-logBuffer:
GlobalWriter.WriteMessage(line, 0)
adapter.Write(line, 0)
case <-time.After(10 * time.Millisecond):
if GlobalWriter.isStdout {
fmt.Fprintf(GlobalWriter,
"%s%s%s %sEOF %s%s\n",
fmt.Printf(
"%s%s%s %sEOF %s%s\n",
dimColor(),
time.Now().Format(timeFormat),
endDimColor(),
dimColor(),
time.Now().Format(timeFormat),
endDimColor(),
blueColor(),
leftArrow,
endColor(),
)
} else {
fmt.Fprintf(GlobalWriter,
"%s EOF %s\n",
time.Now().Format(timeFormat),
leftArrow,
)
}
blueColor(),
leftArrow,
endColor(),
)
return
}
}

View file

@ -6,36 +6,54 @@ import (
"runtime"
"github.com/lmittmann/tint"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
)
func setupSLog(level Severity) {
// Set highest possible level, so it can be changed in runtime.
handlerLogLevel := level.toSLogLevel()
func setupSLog(logLevel Severity) {
// Convert to slog level.
var level slog.Level
switch logLevel {
case TraceLevel:
level = slog.LevelDebug
case DebugLevel:
level = slog.LevelDebug
case InfoLevel:
level = slog.LevelInfo
case WarningLevel:
level = slog.LevelWarn
case ErrorLevel:
level = slog.LevelError
case CriticalLevel:
level = slog.LevelError
}
// Setup logging.
// Define output.
logOutput := os.Stdout
// Create handler depending on OS.
var logHandler slog.Handler
switch runtime.GOOS {
case "windows":
logHandler = tint.NewHandler(
GlobalWriter,
colorable.NewColorable(logOutput),
&tint.Options{
AddSource: true,
Level: handlerLogLevel,
Level: level,
TimeFormat: timeFormat,
NoColor: !GlobalWriter.IsStdout(), // FIXME: also check for tty.
},
)
case "linux":
logHandler = tint.NewHandler(GlobalWriter, &tint.Options{
logHandler = tint.NewHandler(logOutput, &tint.Options{
AddSource: true,
Level: handlerLogLevel,
Level: level,
TimeFormat: timeFormat,
NoColor: !GlobalWriter.IsStdout(), // FIXME: also check for tty.
NoColor: !isatty.IsTerminal(logOutput.Fd()),
})
default:
logHandler = tint.NewHandler(os.Stdout, &tint.Options{
AddSource: true,
Level: handlerLogLevel,
Level: level,
TimeFormat: timeFormat,
NoColor: true,
})
@ -43,6 +61,5 @@ func setupSLog(level Severity) {
// Set as default logger.
slog.SetDefault(slog.New(logHandler))
// Set actual log level.
slog.SetLogLoggerLevel(handlerLogLevel)
slog.SetLogLoggerLevel(level)
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
@ -23,8 +24,36 @@ var key = ContextTracerKey{}
// AddTracer adds a ContextTracer to the returned Context. Will return a nil ContextTracer if logging level is not set to trace. Will return a nil ContextTracer if one already exists. Will return a nil ContextTracer in case of an error. Will return a nil context if nil.
func AddTracer(ctx context.Context) (context.Context, *ContextTracer) {
if ctx != nil && fastcheck(TraceLevel) {
// Check log level.
if atomic.LoadUint32(logLevel) > uint32(TraceLevel) {
// check pkg levels
if pkgLevelsActive.IsSet() {
// get file
_, file, _, ok := runtime.Caller(1)
if !ok {
// cannot get file, ignore
return ctx, nil
}
pathSegments := strings.Split(file, "/")
if len(pathSegments) < 2 {
// file too short for package levels
return ctx, nil
}
pkgLevelsLock.Lock()
severity, ok := pkgLevels[pathSegments[len(pathSegments)-2]]
pkgLevelsLock.Unlock()
if ok {
// check against package level
if TraceLevel < severity {
return ctx, nil
}
} else {
// no package level set, check against global level
if uint32(TraceLevel) < atomic.LoadUint32(logLevel) {
return ctx, nil
}
}
} else if uint32(TraceLevel) < atomic.LoadUint32(logLevel) {
// no package levels set, check against global level
return ctx, nil
}

View file

@ -1,119 +0,0 @@
package log
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// GlobalWriter is the global log writer.
var GlobalWriter *LogWriter = nil
type LogWriter struct {
writeLock sync.Mutex
isStdout bool
file *os.File
}
// NewStdoutWriter creates a new log writer thet will write to the stdout.
func NewStdoutWriter() *LogWriter {
return &LogWriter{
file: os.Stdout,
isStdout: true,
}
}
// NewFileWriter creates a new log writer that will write to a file. The file path will be <dir>/2006-01-02_15-04-05.log (with current date and time)
func NewFileWriter(dir string) (*LogWriter, error) {
// Make sure log dir exists, if not, create with strict permission, as logs can contain sensitive data.
_ = os.MkdirAll(dir, 0o700)
// Open new log file.
logFile := time.Now().UTC().Format("2006-01-02_15-04-05") + ".log"
file, err := os.Create(filepath.Join(dir, logFile))
if err != nil {
return nil, err
}
return &LogWriter{
file: file,
isStdout: false,
}, nil
}
// Write writes the buffer to the writer.
func (l *LogWriter) Write(buf []byte) (int, error) {
if l == nil {
return 0, fmt.Errorf("log writer not initialized")
}
// No need to lock in stdout context.
if !l.isStdout {
l.writeLock.Lock()
defer l.writeLock.Unlock()
}
return l.file.Write(buf)
}
// WriteMessage writes the message to the writer.
func (l *LogWriter) WriteMessage(msg Message, duplicates uint64) {
if l == nil {
return
}
// No need to lock in stdout context.
if !l.isStdout {
l.writeLock.Lock()
defer l.writeLock.Unlock()
}
fmt.Fprintln(l.file, formatLine(msg.(*logLine), duplicates, l.isStdout))
}
// IsStdout returns true if writer was initialized with stdout.
func (l *LogWriter) IsStdout() bool {
return l != nil && l.isStdout
}
// Close closes the writer.
func (l *LogWriter) Close() {
if l != nil && !l.isStdout {
_ = l.file.Close()
}
}
// CleanOldLogs deletes all log files in given directory that are older than the given threshold.
func CleanOldLogs(dir string, threshold time.Duration) error {
// Get current log file name.
var currentLogFile string
if GlobalWriter != nil && GlobalWriter.file != nil {
currentLogFile = GlobalWriter.file.Name()
}
// Read dir entries.
files, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("failed to read dir: %w", err)
}
// Remove files older than threshold
deleteOlderThan := time.Now().Add(-threshold)
for _, f := range files {
// Skip directories and the current log file.
if f.IsDir() || f.Name() == currentLogFile {
continue
}
// Delete log files.
if fileInfo, err := f.Info(); err == nil {
if fileInfo.ModTime().Before(deleteOlderThan) {
_ = os.Remove(filepath.Join(dir, f.Name()))
}
}
}
return nil
}

View file

@ -10,6 +10,7 @@ import (
"github.com/shirou/gopsutil/mem"
"github.com/safing/portmaster/base/api"
"github.com/safing/portmaster/base/dataroot"
"github.com/safing/portmaster/base/log"
)
@ -208,9 +209,18 @@ func getDiskStat() *disk.UsageStat {
return diskStat
}
// Check if we have a data root.
dataRoot := dataroot.Root()
if dataRoot == nil {
log.Warning("metrics: cannot get disk stats without data root")
diskStat = nil
diskStatExpires = time.Now().Add(hostStatTTL)
return diskStat
}
// Refresh.
var err error
diskStat, err = disk.Usage(module.instance.DataDir())
diskStat, err = disk.Usage(dataRoot.Path)
if err != nil {
log.Warningf("metrics: failed to get load avg: %s", err)
diskStat = nil

View file

@ -213,6 +213,4 @@ func New(instance instance) (*Metrics, error) {
return module, nil
}
type instance interface {
DataDir() string
}
type instance interface{}

View file

@ -36,47 +36,6 @@ func (n *Notifications) Stop() error {
return nil
}
// NotifyInfo is a helper method for quickly showing an info notification.
// The notification will be activated immediately.
// If the provided id is empty, an id will derived from msg.
// ShowOnSystem is disabled.
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
func (n *Notifications) NotifyInfo(id, title, msg string, actions ...Action) *Notification {
return NotifyInfo(id, title, msg, actions...)
}
// NotifyWarn is a helper method for quickly showing a warning notification
// The notification will be activated immediately.
// If the provided id is empty, an id will derived from msg.
// ShowOnSystem is enabled.
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
func (n *Notifications) NotifyWarn(id, title, msg string, actions ...Action) *Notification {
return NotifyWarn(id, title, msg, actions...)
}
// NotifyError is a helper method for quickly showing an error notification.
// The notification will be activated immediately.
// If the provided id is empty, an id will derived from msg.
// ShowOnSystem is enabled.
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
func (n *Notifications) NotifyError(id, title, msg string, actions ...Action) *Notification {
return NotifyError(id, title, msg, actions...)
}
// NotifyPrompt is a helper method for quickly showing a prompt notification.
// The notification will be activated immediately.
// If the provided id is empty, an id will derived from msg.
// ShowOnSystem is disabled.
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
func (n *Notifications) NotifyPrompt(id, title, msg string, actions ...Action) *Notification {
return NotifyPrompt(id, title, msg, actions...)
}
// Notify sends the given notification.
func (n *Notifications) Notify(notification *Notification) *Notification {
return Notify(notification)
}
func prep() error {
return registerConfig()
}

2
base/updater/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package updater is an update registry that manages updates and versions.
package updater

15
base/updater/export.go Normal file
View file

@ -0,0 +1,15 @@
package updater
// Export exports the list of resources.
func (reg *ResourceRegistry) Export() map[string]*Resource {
reg.RLock()
defer reg.RUnlock()
// copy the map
copiedResources := make(map[string]*Resource)
for key, val := range reg.resources {
copiedResources[key] = val.Export()
}
return copiedResources
}

347
base/updater/fetch.go Normal file
View file

@ -0,0 +1,347 @@
package updater
import (
"bytes"
"context"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"time"
"github.com/safing/jess/filesig"
"github.com/safing/jess/lhash"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils/renameio"
)
func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client, rv *ResourceVersion, tries int) error {
// backoff when retrying
if tries > 0 {
select {
case <-ctx.Done():
return nil // module is shutting down
case <-time.After(time.Duration(tries*tries) * time.Second):
}
}
// check destination dir
dirPath := filepath.Dir(rv.storagePath())
err := reg.storageDir.EnsureAbsPath(dirPath)
if err != nil {
return fmt.Errorf("could not create updates folder: %s", dirPath)
}
// If verification is enabled, download signature first.
var (
verifiedHash *lhash.LabeledHash
sigFileData []byte
)
if rv.resource.VerificationOptions != nil {
verifiedHash, sigFileData, err = reg.fetchAndVerifySigFile(
ctx, client,
rv.resource.VerificationOptions,
rv.versionedSigPath(), rv.SigningMetadata(),
tries,
)
if err != nil {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("signature verification failed: %w", err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
}
}
}
// open file for writing
atomicFile, err := renameio.TempFile(reg.tmpDir.Path, rv.storagePath())
if err != nil {
return fmt.Errorf("could not create temp file for download: %w", err)
}
defer atomicFile.Cleanup() //nolint:errcheck // ignore error for now, tmp dir will be cleaned later again anyway
// start file download
resp, downloadURL, err := reg.makeRequest(ctx, client, rv.versionedPath(), tries)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
// Write to the hasher at the same time, if needed.
var hasher hash.Hash
var writeDst io.Writer = atomicFile
if verifiedHash != nil {
hasher = verifiedHash.Algorithm().RawHasher()
writeDst = io.MultiWriter(hasher, atomicFile)
}
// Download and write file.
n, err := io.Copy(writeDst, resp.Body)
if err != nil {
return fmt.Errorf("failed to download %q: %w", downloadURL, err)
}
if resp.ContentLength != n {
return fmt.Errorf("failed to finish download of %q: written %d out of %d bytes", downloadURL, n, resp.ContentLength)
}
// Before file is finalized, check if hash, if available.
if hasher != nil {
downloadDigest := hasher.Sum(nil)
if verifiedHash.EqualRaw(downloadDigest) {
log.Infof("%s: verified signature of %s", reg.Name, downloadURL)
} else {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return errors.New("file does not match signed checksum")
case SignaturePolicyWarn:
log.Warningf("%s: checksum does not match file from %s", reg.Name, downloadURL)
case SignaturePolicyDisable:
log.Debugf("%s: checksum does not match file from %s", reg.Name, downloadURL)
}
// Reset hasher to signal that the sig should not be written.
hasher = nil
}
}
// Write signature file, if we have one and if verification succeeded.
if len(sigFileData) > 0 && hasher != nil {
sigFilePath := rv.storagePath() + filesig.Extension
err := os.WriteFile(sigFilePath, sigFileData, 0o0644) //nolint:gosec
if err != nil {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("failed to write signature file %s: %w", sigFilePath, err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to write signature file %s: %s", reg.Name, sigFilePath, err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to write signature file %s: %s", reg.Name, sigFilePath, err)
}
}
}
// finalize file
err = atomicFile.CloseAtomicallyReplace()
if err != nil {
return fmt.Errorf("%s: failed to finalize file %s: %w", reg.Name, rv.storagePath(), err)
}
// set permissions
if !onWindows {
// TODO: only set executable files to 0755, set other to 0644
err = os.Chmod(rv.storagePath(), 0o0755) //nolint:gosec // See TODO above.
if err != nil {
log.Warningf("%s: failed to set permissions on downloaded file %s: %s", reg.Name, rv.storagePath(), err)
}
}
log.Debugf("%s: fetched %s and stored to %s", reg.Name, downloadURL, rv.storagePath())
return nil
}
func (reg *ResourceRegistry) fetchMissingSig(ctx context.Context, client *http.Client, rv *ResourceVersion, tries int) error {
// backoff when retrying
if tries > 0 {
select {
case <-ctx.Done():
return nil // module is shutting down
case <-time.After(time.Duration(tries*tries) * time.Second):
}
}
// Check destination dir.
dirPath := filepath.Dir(rv.storagePath())
err := reg.storageDir.EnsureAbsPath(dirPath)
if err != nil {
return fmt.Errorf("could not create updates folder: %s", dirPath)
}
// Download and verify the missing signature.
verifiedHash, sigFileData, err := reg.fetchAndVerifySigFile(
ctx, client,
rv.resource.VerificationOptions,
rv.versionedSigPath(), rv.SigningMetadata(),
tries,
)
if err != nil {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("signature verification failed: %w", err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
}
return nil
}
// Check if the signature matches the resource file.
ok, err := verifiedHash.MatchesFile(rv.storagePath())
if err != nil {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("error while verifying resource file: %w", err)
case SignaturePolicyWarn:
log.Warningf("%s: error while verifying resource file %s", reg.Name, rv.storagePath())
case SignaturePolicyDisable:
log.Debugf("%s: error while verifying resource file %s", reg.Name, rv.storagePath())
}
return nil
}
if !ok {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return errors.New("resource file does not match signed checksum")
case SignaturePolicyWarn:
log.Warningf("%s: checksum does not match resource file from %s", reg.Name, rv.storagePath())
case SignaturePolicyDisable:
log.Debugf("%s: checksum does not match resource file from %s", reg.Name, rv.storagePath())
}
return nil
}
// Write signature file.
err = os.WriteFile(rv.storageSigPath(), sigFileData, 0o0644) //nolint:gosec
if err != nil {
switch rv.resource.VerificationOptions.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("failed to write signature file %s: %w", rv.storageSigPath(), err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to write signature file %s: %s", reg.Name, rv.storageSigPath(), err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to write signature file %s: %s", reg.Name, rv.storageSigPath(), err)
}
}
log.Debugf("%s: fetched %s and stored to %s", reg.Name, rv.versionedSigPath(), rv.storageSigPath())
return nil
}
func (reg *ResourceRegistry) fetchAndVerifySigFile(ctx context.Context, client *http.Client, verifOpts *VerificationOptions, sigFilePath string, requiredMetadata map[string]string, tries int) (*lhash.LabeledHash, []byte, error) {
// Download signature file.
resp, _, err := reg.makeRequest(ctx, client, sigFilePath, tries)
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
sigFileData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
// Extract all signatures.
sigs, err := filesig.ParseSigFile(sigFileData)
switch {
case len(sigs) == 0 && err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
case len(sigs) == 0:
return nil, nil, errors.New("no signatures found in signature file")
case err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
}
// Verify all signatures.
var verifiedHash *lhash.LabeledHash
for _, sig := range sigs {
fd, err := filesig.VerifyFileData(
sig,
requiredMetadata,
verifOpts.TrustStore,
)
if err != nil {
return nil, sigFileData, err
}
// Save or check verified hash.
if verifiedHash == nil {
verifiedHash = fd.FileHash()
} else if !fd.FileHash().Equal(verifiedHash) {
// Return an error if two valid hashes mismatch.
// For simplicity, all hash algorithms must be the same for now.
return nil, sigFileData, errors.New("file hashes from different signatures do not match")
}
}
return verifiedHash, sigFileData, nil
}
func (reg *ResourceRegistry) fetchData(ctx context.Context, client *http.Client, downloadPath string, tries int) (fileData []byte, downloadedFrom string, err error) {
// backoff when retrying
if tries > 0 {
select {
case <-ctx.Done():
return nil, "", nil // module is shutting down
case <-time.After(time.Duration(tries*tries) * time.Second):
}
}
// start file download
resp, downloadURL, err := reg.makeRequest(ctx, client, downloadPath, tries)
if err != nil {
return nil, downloadURL, err
}
defer func() {
_ = resp.Body.Close()
}()
// download and write file
buf := bytes.NewBuffer(make([]byte, 0, resp.ContentLength))
n, err := io.Copy(buf, resp.Body)
if err != nil {
return nil, downloadURL, fmt.Errorf("failed to download %q: %w", downloadURL, err)
}
if resp.ContentLength != n {
return nil, downloadURL, fmt.Errorf("failed to finish download of %q: written %d out of %d bytes", downloadURL, n, resp.ContentLength)
}
return buf.Bytes(), downloadURL, nil
}
func (reg *ResourceRegistry) makeRequest(ctx context.Context, client *http.Client, downloadPath string, tries int) (resp *http.Response, downloadURL string, err error) {
// parse update URL
updateBaseURL := reg.UpdateURLs[tries%len(reg.UpdateURLs)]
u, err := url.Parse(updateBaseURL)
if err != nil {
return nil, "", fmt.Errorf("failed to parse update URL %q: %w", updateBaseURL, err)
}
// add download path
u.Path = path.Join(u.Path, downloadPath)
// compile URL
downloadURL = u.String()
// create request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
if err != nil {
return nil, "", fmt.Errorf("failed to create request for %q: %w", downloadURL, err)
}
// set user agent
if reg.UserAgent != "" {
req.Header.Set("User-Agent", reg.UserAgent)
}
// start request
resp, err = client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("failed to make request to %q: %w", downloadURL, err)
}
// check return code
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, "", fmt.Errorf("failed to fetch %q: %d %s", downloadURL, resp.StatusCode, resp.Status)
}
return resp, downloadURL, err
}

156
base/updater/file.go Normal file
View file

@ -0,0 +1,156 @@
package updater
import (
"errors"
"io"
"io/fs"
"os"
"strings"
semver "github.com/hashicorp/go-version"
"github.com/safing/jess/filesig"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
// File represents a file from the update system.
type File struct {
resource *Resource
version *ResourceVersion
notifier *notifier
versionedPath string
storagePath string
}
// Identifier returns the identifier of the file.
func (file *File) Identifier() string {
return file.resource.Identifier
}
// Version returns the version of the file.
func (file *File) Version() string {
return file.version.VersionNumber
}
// SemVer returns the semantic version of the file.
func (file *File) SemVer() *semver.Version {
return file.version.semVer
}
// EqualsVersion normalizes the given version and checks equality with semver.
func (file *File) EqualsVersion(version string) bool {
return file.version.EqualsVersion(version)
}
// Path returns the absolute filepath of the file.
func (file *File) Path() string {
return file.storagePath
}
// SigningMetadata returns the metadata to be included in signatures.
func (file *File) SigningMetadata() map[string]string {
return map[string]string{
"id": file.Identifier(),
"version": file.Version(),
}
}
// Verify verifies the given file.
func (file *File) Verify() ([]*filesig.FileData, error) {
// Check if verification is configured.
if file.resource.VerificationOptions == nil {
return nil, ErrVerificationNotConfigured
}
// Verify file.
fileData, err := filesig.VerifyFile(
file.storagePath,
file.storagePath+filesig.Extension,
file.SigningMetadata(),
file.resource.VerificationOptions.TrustStore,
)
if err != nil {
switch file.resource.VerificationOptions.DiskLoadPolicy {
case SignaturePolicyRequire:
return nil, err
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify %s: %s", file.resource.registry.Name, file.storagePath, err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify %s: %s", file.resource.registry.Name, file.storagePath, err)
}
}
return fileData, nil
}
// Blacklist notifies the update system that this file is somehow broken, and should be ignored from now on, until restarted.
func (file *File) Blacklist() error {
return file.resource.Blacklist(file.version.VersionNumber)
}
// markActiveWithLocking marks the file as active, locking the resource in the process.
func (file *File) markActiveWithLocking() {
file.resource.Lock()
defer file.resource.Unlock()
// update last used version
if file.resource.ActiveVersion != file.version {
log.Debugf("updater: setting active version of resource %s from %s to %s", file.resource.Identifier, file.resource.ActiveVersion, file.version.VersionNumber)
file.resource.ActiveVersion = file.version
}
}
// Unpacker describes the function that is passed to
// File.Unpack. It receives a reader to the compressed/packed
// file and should return a reader that provides
// unpacked file contents. If the returned reader implements
// io.Closer it's close method is invoked when an error
// or io.EOF is returned from Read().
type Unpacker func(io.Reader) (io.Reader, error)
// Unpack returns the path to the unpacked version of file and
// unpacks it on demand using unpacker.
func (file *File) Unpack(suffix string, unpacker Unpacker) (string, error) {
path := strings.TrimSuffix(file.Path(), suffix)
if suffix == "" {
path += "-unpacked"
}
_, err := os.Stat(path)
if err == nil {
return path, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
f, err := os.Open(file.Path())
if err != nil {
return "", err
}
defer func() {
_ = f.Close()
}()
r, err := unpacker(f)
if err != nil {
return "", err
}
ioErr := utils.CreateAtomic(path, r, &utils.AtomicFileOptions{
TempDir: file.resource.registry.TmpDir().Path,
})
if c, ok := r.(io.Closer); ok {
if err := c.Close(); err != nil && ioErr == nil {
// if ioErr is already set we ignore the error from
// closing the unpacker.
ioErr = err
}
}
return path, ioErr
}

57
base/updater/filename.go Normal file
View file

@ -0,0 +1,57 @@
package updater
import (
"path"
"regexp"
"strings"
)
var (
fileVersionRegex = regexp.MustCompile(`_v[0-9]+-[0-9]+-[0-9]+(-[a-z]+)?`)
rawVersionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+)?$`)
)
// GetIdentifierAndVersion splits the given file path into its identifier and version.
func GetIdentifierAndVersion(versionedPath string) (identifier, version string, ok bool) {
dirPath, filename := path.Split(versionedPath)
// Extract version from filename.
rawVersion := fileVersionRegex.FindString(filename)
if rawVersion == "" {
// No version present in file, making it invalid.
return "", "", false
}
// Trim the `_v` that gets caught by the regex and
// replace `-` with `.` to get the version string.
version = strings.Replace(strings.TrimLeft(rawVersion, "_v"), "-", ".", 2)
// Put the filename back together without version.
i := strings.Index(filename, rawVersion)
if i < 0 {
// extracted version not in string (impossible)
return "", "", false
}
filename = filename[:i] + filename[i+len(rawVersion):]
// Put the full path back together and return it.
// `dirPath + filename` is guaranteed by path.Split()
return dirPath + filename, version, true
}
// GetVersionedPath combines the identifier and version and returns it as a file path.
func GetVersionedPath(identifier, version string) (versionedPath string) {
identifierPath, filename := path.Split(identifier)
// Split the filename where the version should go.
splittedFilename := strings.SplitN(filename, ".", 2)
// Replace `.` with `-` for the filename format.
transformedVersion := strings.Replace(version, ".", "-", 2)
// Put everything back together and return it.
versionedPath = identifierPath + splittedFilename[0] + "_v" + transformedVersion
if len(splittedFilename) > 1 {
versionedPath += "." + splittedFilename[1]
}
return versionedPath
}

View file

@ -0,0 +1,80 @@
package updater
import (
"regexp"
"testing"
"github.com/stretchr/testify/assert"
)
func testRegexMatch(t *testing.T, testRegex *regexp.Regexp, testString string, shouldMatch bool) {
t.Helper()
if testRegex.MatchString(testString) != shouldMatch {
if shouldMatch {
t.Errorf("regex %s should match %s", testRegex, testString)
} else {
t.Errorf("regex %s should not match %s", testRegex, testString)
}
}
}
func testRegexFind(t *testing.T, testRegex *regexp.Regexp, testString string, shouldMatch bool) {
t.Helper()
if (testRegex.FindString(testString) != "") != shouldMatch {
if shouldMatch {
t.Errorf("regex %s should find %s", testRegex, testString)
} else {
t.Errorf("regex %s should not find %s", testRegex, testString)
}
}
}
func testVersionTransformation(t *testing.T, testFilename, testIdentifier, testVersion string) {
t.Helper()
identifier, version, ok := GetIdentifierAndVersion(testFilename)
if !ok {
t.Errorf("failed to get identifier and version of %s", testFilename)
}
assert.Equal(t, testIdentifier, identifier, "identifier does not match")
assert.Equal(t, testVersion, version, "version does not match")
versionedPath := GetVersionedPath(testIdentifier, testVersion)
assert.Equal(t, testFilename, versionedPath, "filename (versioned path) does not match")
}
func TestRegexes(t *testing.T) {
t.Parallel()
testRegexMatch(t, rawVersionRegex, "0.1.2", true)
testRegexMatch(t, rawVersionRegex, "0.1.2-beta", true)
testRegexMatch(t, rawVersionRegex, "0.1.2-staging", true)
testRegexMatch(t, rawVersionRegex, "12.13.14", true)
testRegexMatch(t, rawVersionRegex, "v0.1.2", false)
testRegexMatch(t, rawVersionRegex, "0.", false)
testRegexMatch(t, rawVersionRegex, "0.1", false)
testRegexMatch(t, rawVersionRegex, "0.1.", false)
testRegexMatch(t, rawVersionRegex, ".1.2", false)
testRegexMatch(t, rawVersionRegex, ".1.", false)
testRegexMatch(t, rawVersionRegex, "012345", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v0-0-0", true)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2-3", true)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2-3.exe", true)
testRegexFind(t, fileVersionRegex, "/path/to/file-v1-2-3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1.2.3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_1-2-3", false)
testRegexFind(t, fileVersionRegex, "/path/to/file_v1-2", false)
testRegexFind(t, fileVersionRegex, "/path/to/file-v1-2-3", false)
testVersionTransformation(t, "/path/to/file_v0-0-0", "/path/to/file", "0.0.0")
testVersionTransformation(t, "/path/to/file_v1-2-3", "/path/to/file", "1.2.3")
testVersionTransformation(t, "/path/to/file_v1-2-3-beta", "/path/to/file", "1.2.3-beta")
testVersionTransformation(t, "/path/to/file_v1-2-3-staging", "/path/to/file", "1.2.3-staging")
testVersionTransformation(t, "/path/to/file_v1-2-3.exe", "/path/to/file.exe", "1.2.3")
testVersionTransformation(t, "/path/to/file_v1-2-3-staging.exe", "/path/to/file.exe", "1.2.3-staging")
}

91
base/updater/get.go Normal file
View file

@ -0,0 +1,91 @@
package updater
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/safing/portmaster/base/log"
)
// Errors returned by the updater package.
var (
ErrNotFound = errors.New("the requested file could not be found")
ErrNotAvailableLocally = errors.New("the requested file is not available locally")
ErrVerificationNotConfigured = errors.New("verification not configured for this resource")
)
// GetFile returns the selected (mostly newest) file with the given
// identifier or an error, if it fails.
func (reg *ResourceRegistry) GetFile(identifier string) (*File, error) {
reg.RLock()
res, ok := reg.resources[identifier]
reg.RUnlock()
if !ok {
return nil, ErrNotFound
}
file := res.GetFile()
// check if file is available locally
if file.version.Available {
file.markActiveWithLocking()
// Verify file, if configured.
_, err := file.Verify()
if err != nil && !errors.Is(err, ErrVerificationNotConfigured) {
// TODO: If verification is required, try deleting the resource and downloading it again.
return nil, fmt.Errorf("failed to verify file: %w", err)
}
return file, nil
}
// check if online
if !reg.Online {
return nil, ErrNotAvailableLocally
}
// check download dir
err := reg.tmpDir.Ensure()
if err != nil {
return nil, fmt.Errorf("could not prepare tmp directory for download: %w", err)
}
// Start registry operation.
reg.state.StartOperation(StateFetching)
defer reg.state.EndOperation()
// download file
log.Tracef("%s: starting download of %s", reg.Name, file.versionedPath)
client := &http.Client{}
for tries := range 5 {
err = reg.fetchFile(context.TODO(), client, file.version, tries)
if err != nil {
log.Tracef("%s: failed to download %s: %s, retrying (%d)", reg.Name, file.versionedPath, err, tries+1)
} else {
file.markActiveWithLocking()
// TODO: We just download the file - should we verify it again?
return file, nil
}
}
log.Warningf("%s: failed to download %s: %s", reg.Name, file.versionedPath, err)
return nil, err
}
// GetVersion returns the selected version of the given identifier.
// The returned resource version may not be modified.
func (reg *ResourceRegistry) GetVersion(identifier string) (*ResourceVersion, error) {
reg.RLock()
res, ok := reg.resources[identifier]
reg.RUnlock()
if !ok {
return nil, ErrNotFound
}
res.Lock()
defer res.Unlock()
return res.SelectedVersion, nil
}

109
base/updater/indexes.go Normal file
View file

@ -0,0 +1,109 @@
package updater
import (
"encoding/json"
"errors"
"fmt"
"time"
)
const (
baseIndexExtension = ".json"
v2IndexExtension = ".v2.json"
)
// Index describes an index file pulled by the updater.
type Index struct {
// Path is the path to the index file
// on the update server.
Path string
// Channel holds the release channel name of the index.
// It must match the filename without extension.
Channel string
// PreRelease signifies that all versions of this index should be marked as
// pre-releases, no matter if the versions actually have a pre-release tag or
// not.
PreRelease bool
// AutoDownload specifies whether new versions should be automatically downloaded.
AutoDownload bool
// LastRelease holds the time of the last seen release of this index.
LastRelease time.Time
}
// IndexFile represents an index file.
type IndexFile struct {
Channel string
Published time.Time
Releases map[string]string
}
var (
// ErrIndexChecksumMismatch is returned when an index does not match its
// signed checksum.
ErrIndexChecksumMismatch = errors.New("index checksum does mot match signature")
// ErrIndexFromFuture is returned when an index is parsed with a
// Published timestamp that lies in the future.
ErrIndexFromFuture = errors.New("index is from the future")
// ErrIndexIsOlder is returned when an index is parsed with an older
// Published timestamp than the current Published timestamp.
ErrIndexIsOlder = errors.New("index is older than the current one")
// ErrIndexChannelMismatch is returned when an index is parsed with a
// different channel that the expected one.
ErrIndexChannelMismatch = errors.New("index does not match the expected channel")
)
// ParseIndexFile parses an index file and checks if it is valid.
func ParseIndexFile(indexData []byte, channel string, lastIndexRelease time.Time) (*IndexFile, error) {
// Load into struct.
indexFile := &IndexFile{}
err := json.Unmarshal(indexData, indexFile)
if err != nil {
return nil, fmt.Errorf("failed to parse signed index data: %w", err)
}
// Fallback to old format if there are no releases and no channel is defined.
// TODO: Remove in v1
if len(indexFile.Releases) == 0 && indexFile.Channel == "" {
return loadOldIndexFormat(indexData, channel)
}
// Check the index metadata.
switch {
case !indexFile.Published.IsZero() && time.Now().Before(indexFile.Published):
return indexFile, ErrIndexFromFuture
case !indexFile.Published.IsZero() &&
!lastIndexRelease.IsZero() &&
lastIndexRelease.After(indexFile.Published):
return indexFile, ErrIndexIsOlder
case channel != "" &&
indexFile.Channel != "" &&
channel != indexFile.Channel:
return indexFile, ErrIndexChannelMismatch
}
return indexFile, nil
}
func loadOldIndexFormat(indexData []byte, channel string) (*IndexFile, error) {
releases := make(map[string]string)
err := json.Unmarshal(indexData, &releases)
if err != nil {
return nil, err
}
return &IndexFile{
Channel: channel,
// Do NOT define `Published`, as this would break the "is newer" check.
Releases: releases,
}, nil
}

View file

@ -0,0 +1,57 @@
package updater
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var (
oldFormat = `{
"all/ui/modules/assets.zip": "0.3.0",
"all/ui/modules/portmaster.zip": "0.2.4",
"linux_amd64/core/portmaster-core": "0.8.13"
}`
newFormat = `{
"Channel": "stable",
"Published": "2022-01-02T00:00:00Z",
"Releases": {
"all/ui/modules/assets.zip": "0.3.0",
"all/ui/modules/portmaster.zip": "0.2.4",
"linux_amd64/core/portmaster-core": "0.8.13"
}
}`
formatTestChannel = "stable"
formatTestReleases = map[string]string{
"all/ui/modules/assets.zip": "0.3.0",
"all/ui/modules/portmaster.zip": "0.2.4",
"linux_amd64/core/portmaster-core": "0.8.13",
}
)
func TestIndexParsing(t *testing.T) {
t.Parallel()
lastRelease, err := time.Parse(time.RFC3339, "2022-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
oldIndexFile, err := ParseIndexFile([]byte(oldFormat), formatTestChannel, lastRelease)
if err != nil {
t.Fatal(err)
}
newIndexFile, err := ParseIndexFile([]byte(newFormat), formatTestChannel, lastRelease)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, formatTestChannel, oldIndexFile.Channel, "channel should be the same")
assert.Equal(t, formatTestChannel, newIndexFile.Channel, "channel should be the same")
assert.Equal(t, formatTestReleases, oldIndexFile.Releases, "releases should be the same")
assert.Equal(t, formatTestReleases, newIndexFile.Releases, "releases should be the same")
}

33
base/updater/notifier.go Normal file
View file

@ -0,0 +1,33 @@
package updater
import (
"github.com/tevino/abool"
)
type notifier struct {
upgradeAvailable *abool.AtomicBool
notifyChannel chan struct{}
}
func newNotifier() *notifier {
return &notifier{
upgradeAvailable: abool.NewBool(false),
notifyChannel: make(chan struct{}),
}
}
func (n *notifier) markAsUpgradeable() {
if n.upgradeAvailable.SetToIf(false, true) {
close(n.notifyChannel)
}
}
// UpgradeAvailable returns whether an upgrade is available for this file.
func (file *File) UpgradeAvailable() bool {
return file.notifier.upgradeAvailable.IsSet()
}
// WaitForAvailableUpgrade blocks (selectable) until an upgrade for this file is available.
func (file *File) WaitForAvailableUpgrade() <-chan struct{} {
return file.notifier.notifyChannel
}

270
base/updater/registry.go Normal file
View file

@ -0,0 +1,270 @@
package updater
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
const (
onWindows = runtime.GOOS == "windows"
)
// ResourceRegistry is a registry for managing update resources.
type ResourceRegistry struct {
sync.RWMutex
Name string
storageDir *utils.DirStructure
tmpDir *utils.DirStructure
indexes []*Index
state *RegistryState
resources map[string]*Resource
UpdateURLs []string
UserAgent string
MandatoryUpdates []string
AutoUnpack []string
// Verification holds a map of VerificationOptions assigned to their
// applicable identifier path prefix.
// Use an empty string to denote the default.
// Use empty options to disable verification for a path prefix.
Verification map[string]*VerificationOptions
// UsePreReleases signifies that pre-releases should be used when selecting a
// version. Even if false, a pre-release version will still be used if it is
// defined as the current version by an index.
UsePreReleases bool
// DevMode specifies if a local 0.0.0 version should be always chosen, when available.
DevMode bool
// Online specifies if resources may be downloaded if not available locally.
Online bool
// StateNotifyFunc may be set to receive any changes to the registry state.
// The specified function may lock the state, but may not block or take a
// lot of time.
StateNotifyFunc func(*RegistryState)
}
// AddIndex adds a new index to the resource registry.
// The order is important, as indexes added later will override the current
// release from earlier indexes.
func (reg *ResourceRegistry) AddIndex(idx Index) {
reg.Lock()
defer reg.Unlock()
// Get channel name from path.
idx.Channel = strings.TrimSuffix(
filepath.Base(idx.Path), filepath.Ext(idx.Path),
)
reg.indexes = append(reg.indexes, &idx)
}
// PreInitUpdateState sets the initial update state of the registry before initialization.
func (reg *ResourceRegistry) PreInitUpdateState(s UpdateState) error {
if reg.state != nil {
return errors.New("registry already initialized")
}
reg.state = &RegistryState{
Updates: s,
}
return nil
}
// Initialize initializes a raw registry struct and makes it ready for usage.
func (reg *ResourceRegistry) Initialize(storageDir *utils.DirStructure) error {
// check if storage dir is available
err := storageDir.Ensure()
if err != nil {
return err
}
// set default name
if reg.Name == "" {
reg.Name = "updater"
}
// initialize private attributes
reg.storageDir = storageDir
reg.tmpDir = storageDir.ChildDir("tmp", 0o0700)
reg.resources = make(map[string]*Resource)
if reg.state == nil {
reg.state = &RegistryState{}
}
reg.state.ID = StateReady
reg.state.reg = reg
// remove tmp dir to delete old entries
err = reg.Cleanup()
if err != nil {
log.Warningf("%s: failed to remove tmp dir: %s", reg.Name, err)
}
// (re-)create tmp dir
err = reg.tmpDir.Ensure()
if err != nil {
log.Warningf("%s: failed to create tmp dir: %s", reg.Name, err)
}
// Check verification options.
if reg.Verification != nil {
for prefix, opts := range reg.Verification {
// Check if verification is disable for this prefix.
if opts == nil {
continue
}
// If enabled, a trust store is required.
if opts.TrustStore == nil {
return fmt.Errorf("verification enabled for prefix %q, but no trust store configured", prefix)
}
// DownloadPolicy must be equal or stricter than DiskLoadPolicy.
if opts.DiskLoadPolicy < opts.DownloadPolicy {
return errors.New("verification download policy must be equal or stricter than the disk load policy")
}
// Warn if all policies are disabled.
if opts.DownloadPolicy == SignaturePolicyDisable &&
opts.DiskLoadPolicy == SignaturePolicyDisable {
log.Warningf("%s: verification enabled for prefix %q, but all policies set to disable", reg.Name, prefix)
}
}
}
return nil
}
// StorageDir returns the main storage dir of the resource registry.
func (reg *ResourceRegistry) StorageDir() *utils.DirStructure {
return reg.storageDir
}
// TmpDir returns the temporary working dir of the resource registry.
func (reg *ResourceRegistry) TmpDir() *utils.DirStructure {
return reg.tmpDir
}
// SetDevMode sets the development mode flag.
func (reg *ResourceRegistry) SetDevMode(on bool) {
reg.Lock()
defer reg.Unlock()
reg.DevMode = on
}
// SetUsePreReleases sets the UsePreReleases flag.
func (reg *ResourceRegistry) SetUsePreReleases(yes bool) {
reg.Lock()
defer reg.Unlock()
reg.UsePreReleases = yes
}
// AddResource adds a resource to the registry. Does _not_ select new version.
func (reg *ResourceRegistry) AddResource(identifier, version string, index *Index, available, currentRelease, preRelease bool) error {
reg.Lock()
defer reg.Unlock()
err := reg.addResource(identifier, version, index, available, currentRelease, preRelease)
return err
}
func (reg *ResourceRegistry) addResource(identifier, version string, index *Index, available, currentRelease, preRelease bool) error {
res, ok := reg.resources[identifier]
if !ok {
res = reg.newResource(identifier)
reg.resources[identifier] = res
}
res.Index = index
return res.AddVersion(version, available, currentRelease, preRelease)
}
// AddResources adds resources to the registry. Errors are logged, the last one is returned. Despite errors, non-failing resources are still added. Does _not_ select new versions.
func (reg *ResourceRegistry) AddResources(versions map[string]string, index *Index, available, currentRelease, preRelease bool) error {
reg.Lock()
defer reg.Unlock()
// add versions and their flags to registry
var lastError error
for identifier, version := range versions {
lastError = reg.addResource(identifier, version, index, available, currentRelease, preRelease)
if lastError != nil {
log.Warningf("%s: failed to add resource %s: %s", reg.Name, identifier, lastError)
}
}
return lastError
}
// SelectVersions selects new resource versions depending on the current registry state.
func (reg *ResourceRegistry) SelectVersions() {
reg.RLock()
defer reg.RUnlock()
for _, res := range reg.resources {
res.Lock()
res.selectVersion()
res.Unlock()
}
}
// GetSelectedVersions returns a list of the currently selected versions.
func (reg *ResourceRegistry) GetSelectedVersions() (versions map[string]string) {
reg.RLock()
defer reg.RUnlock()
for _, res := range reg.resources {
res.Lock()
versions[res.Identifier] = res.SelectedVersion.VersionNumber
res.Unlock()
}
return
}
// Purge deletes old updates, retaining a certain amount, specified by the keep
// parameter. Will at least keep 2 updates per resource.
func (reg *ResourceRegistry) Purge(keep int) {
reg.RLock()
defer reg.RUnlock()
for _, res := range reg.resources {
res.Purge(keep)
}
}
// ResetResources removes all resources from the registry.
func (reg *ResourceRegistry) ResetResources() {
reg.Lock()
defer reg.Unlock()
reg.resources = make(map[string]*Resource)
}
// ResetIndexes removes all indexes from the registry.
func (reg *ResourceRegistry) ResetIndexes() {
reg.Lock()
defer reg.Unlock()
reg.indexes = make([]*Index, 0, len(reg.indexes))
}
// Cleanup removes temporary files.
func (reg *ResourceRegistry) Cleanup() error {
// delete download tmp dir
return os.RemoveAll(reg.tmpDir.Path)
}

View file

@ -0,0 +1,35 @@
package updater
import (
"os"
"testing"
"github.com/safing/portmaster/base/utils"
)
var registry *ResourceRegistry
func TestMain(m *testing.M) {
// setup
tmpDir, err := os.MkdirTemp("", "ci-portmaster-")
if err != nil {
panic(err)
}
registry = &ResourceRegistry{
UsePreReleases: true,
DevMode: true,
Online: true,
}
err = registry.Initialize(utils.NewDirStructure(tmpDir, 0o0777))
if err != nil {
panic(err)
}
// run
// call flag.Parse() here if TestMain uses flags
ret := m.Run()
// teardown
_ = os.RemoveAll(tmpDir)
os.Exit(ret)
}

582
base/updater/resource.go Normal file
View file

@ -0,0 +1,582 @@
package updater
import (
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
semver "github.com/hashicorp/go-version"
"github.com/safing/jess/filesig"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
var devVersion *semver.Version
func init() {
var err error
devVersion, err = semver.NewVersion("0")
if err != nil {
panic(err)
}
}
// Resource represents a resource (via an identifier) and multiple file versions.
type Resource struct {
sync.Mutex
registry *ResourceRegistry
notifier *notifier
// Identifier is the unique identifier for that resource.
// It forms a file path using a forward-slash as the
// path separator.
Identifier string
// Versions holds all available resource versions.
Versions []*ResourceVersion
// ActiveVersion is the last version of the resource
// that someone requested using GetFile().
ActiveVersion *ResourceVersion
// SelectedVersion is newest, selectable version of
// that resource that is available. A version
// is selectable if it's not blacklisted by the user.
// Note that it's not guaranteed that the selected version
// is available locally. In that case, GetFile will attempt
// to download the latest version from the updates servers
// specified in the resource registry.
SelectedVersion *ResourceVersion
// VerificationOptions holds the verification options for this resource.
VerificationOptions *VerificationOptions
// Index holds a reference to the index this resource was last defined in.
// Will be nil if resource was only found on disk.
Index *Index
}
// ResourceVersion represents a single version of a resource.
type ResourceVersion struct {
resource *Resource
// VersionNumber is the string representation of the resource
// version.
VersionNumber string
semVer *semver.Version
// Available indicates if this version is available locally.
Available bool
// SigAvailable indicates if the signature of this version is available locally.
SigAvailable bool
// CurrentRelease indicates that this is the current release that should be
// selected, if possible.
CurrentRelease bool
// PreRelease indicates that this version is pre-release.
PreRelease bool
// Blacklisted may be set to true if this version should
// be skipped and not used. This is useful if the version
// is known to be broken.
Blacklisted bool
}
func (rv *ResourceVersion) String() string {
return rv.VersionNumber
}
// SemVer returns the semantic version of the resource.
func (rv *ResourceVersion) SemVer() *semver.Version {
return rv.semVer
}
// EqualsVersion normalizes the given version and checks equality with semver.
func (rv *ResourceVersion) EqualsVersion(version string) bool {
cmpSemVer, err := semver.NewVersion(version)
if err != nil {
return false
}
return rv.semVer.Equal(cmpSemVer)
}
// isSelectable returns true if the version represented by rv is selectable.
// A version is selectable if it's not blacklisted and either already locally
// available or ready to be downloaded.
func (rv *ResourceVersion) isSelectable() bool {
switch {
case rv.Blacklisted:
// Should not be used.
return false
case rv.Available:
// Is available locally, use!
return true
case !rv.resource.registry.Online:
// Cannot download, because registry is set to offline.
return false
case rv.resource.Index == nil:
// Cannot download, because resource is not part of an index.
return false
case !rv.resource.Index.AutoDownload:
// Cannot download, because index may not automatically download.
return false
default:
// Is not available locally, but we are allowed to download it on request!
return true
}
}
// isBetaVersionNumber checks if rv is marked as a beta version by checking
// the version string. It does not honor the BetaRelease field of rv!
func (rv *ResourceVersion) isBetaVersionNumber() bool { //nolint:unused
// "b" suffix check if for backwards compatibility
// new versions should use the pre-release suffix as
// declared by https://semver.org
// i.e. 1.2.3-beta
switch rv.semVer.Prerelease() {
case "b", "beta":
return true
default:
return false
}
}
// Export makes a copy of the resource with only the exposed information.
// Attributes are copied and safe to access.
// Any ResourceVersion must not be modified.
func (res *Resource) Export() *Resource {
res.Lock()
defer res.Unlock()
// Copy attibutes.
export := &Resource{
Identifier: res.Identifier,
Versions: make([]*ResourceVersion, len(res.Versions)),
ActiveVersion: res.ActiveVersion,
SelectedVersion: res.SelectedVersion,
}
// Copy Versions slice.
copy(export.Versions, res.Versions)
return export
}
// Len is the number of elements in the collection.
// It implements sort.Interface for ResourceVersion.
func (res *Resource) Len() int {
return len(res.Versions)
}
// Less reports whether the element with index i should
// sort before the element with index j.
// It implements sort.Interface for ResourceVersions.
func (res *Resource) Less(i, j int) bool {
return res.Versions[i].semVer.GreaterThan(res.Versions[j].semVer)
}
// Swap swaps the elements with indexes i and j.
// It implements sort.Interface for ResourceVersions.
func (res *Resource) Swap(i, j int) {
res.Versions[i], res.Versions[j] = res.Versions[j], res.Versions[i]
}
// available returns whether any version of the resource is available.
func (res *Resource) available() bool {
for _, rv := range res.Versions {
if rv.Available {
return true
}
}
return false
}
// inUse returns true if the resource is currently in use.
func (res *Resource) inUse() bool {
return res.ActiveVersion != nil
}
// AnyVersionAvailable returns true if any version of
// res is locally available.
func (res *Resource) AnyVersionAvailable() bool {
res.Lock()
defer res.Unlock()
return res.available()
}
func (reg *ResourceRegistry) newResource(identifier string) *Resource {
return &Resource{
registry: reg,
Identifier: identifier,
Versions: make([]*ResourceVersion, 0, 1),
VerificationOptions: reg.GetVerificationOptions(identifier),
}
}
// AddVersion adds a resource version to a resource.
func (res *Resource) AddVersion(version string, available, currentRelease, preRelease bool) error {
res.Lock()
defer res.Unlock()
// reset current release flags
if currentRelease {
for _, rv := range res.Versions {
rv.CurrentRelease = false
}
}
var rv *ResourceVersion
// check for existing version
for _, possibleMatch := range res.Versions {
if possibleMatch.VersionNumber == version {
rv = possibleMatch
break
}
}
// create new version if none found
if rv == nil {
// parse to semver
sv, err := semver.NewVersion(version)
if err != nil {
return err
}
rv = &ResourceVersion{
resource: res,
VersionNumber: sv.String(), // Use normalized version.
semVer: sv,
}
res.Versions = append(res.Versions, rv)
}
// set flags
if available {
rv.Available = true
// If available and signatures are enabled for this resource, check if the
// signature is available.
if res.VerificationOptions != nil && utils.PathExists(rv.storageSigPath()) {
rv.SigAvailable = true
}
}
if currentRelease {
rv.CurrentRelease = true
}
if preRelease || rv.semVer.Prerelease() != "" {
rv.PreRelease = true
}
return nil
}
// GetFile returns the selected version as a *File.
func (res *Resource) GetFile() *File {
res.Lock()
defer res.Unlock()
// check for notifier
if res.notifier == nil {
// create new notifier
res.notifier = newNotifier()
}
// check if version is selected
if res.SelectedVersion == nil {
res.selectVersion()
}
// create file
return &File{
resource: res,
version: res.SelectedVersion,
notifier: res.notifier,
versionedPath: res.SelectedVersion.versionedPath(),
storagePath: res.SelectedVersion.storagePath(),
}
}
//nolint:gocognit // function already kept as simple as possible
func (res *Resource) selectVersion() {
sort.Sort(res)
// export after we finish
var fallback bool
defer func() {
if fallback {
log.Tracef("updater: selected version %s (as fallback) for resource %s", res.SelectedVersion, res.Identifier)
} else {
log.Debugf("updater: selected version %s for resource %s", res.SelectedVersion, res.Identifier)
}
if res.inUse() &&
res.SelectedVersion != res.ActiveVersion && // new selected version does not match previously selected version
res.notifier != nil {
res.notifier.markAsUpgradeable()
res.notifier = nil
log.Debugf("updater: active version of %s is %s, update available", res.Identifier, res.ActiveVersion.VersionNumber)
}
}()
if len(res.Versions) == 0 {
// TODO: find better way to deal with an empty version slice (which should not happen)
res.SelectedVersion = nil
return
}
// Target selection
// 1) Dev release if dev mode is active and ignore blacklisting
if res.registry.DevMode {
// Get last version, as this will be v0.0.0, if available.
rv := res.Versions[len(res.Versions)-1]
// Check if it's v0.0.0.
if rv.semVer.Equal(devVersion) && rv.Available {
res.SelectedVersion = rv
return
}
}
// 2) Find the current release. This may be also be a pre-release.
for _, rv := range res.Versions {
if rv.CurrentRelease {
if rv.isSelectable() {
res.SelectedVersion = rv
return
}
// There can only be once current release,
// so we can abort after finding one.
break
}
}
// 3) If UsePreReleases is set, find any newest version.
if res.registry.UsePreReleases {
for _, rv := range res.Versions {
if rv.isSelectable() {
res.SelectedVersion = rv
return
}
}
}
// 4) Find the newest stable version.
for _, rv := range res.Versions {
if !rv.PreRelease && rv.isSelectable() {
res.SelectedVersion = rv
return
}
}
// 5) Default to newest.
res.SelectedVersion = res.Versions[0]
fallback = true
}
// Blacklist blacklists the specified version and selects a new version.
func (res *Resource) Blacklist(version string) error {
res.Lock()
defer res.Unlock()
// count available and valid versions
valid := 0
for _, rv := range res.Versions {
if rv.semVer.Equal(devVersion) {
continue // ignore dev versions
}
if !rv.Blacklisted {
valid++
}
}
if valid <= 1 {
return errors.New("cannot blacklist last version") // last one, cannot blacklist!
}
// find version and blacklist
for _, rv := range res.Versions {
if rv.VersionNumber == version {
// blacklist and update
rv.Blacklisted = true
res.selectVersion()
return nil
}
}
return errors.New("could not find version")
}
// Purge deletes old updates, retaining a certain amount, specified by
// the keep parameter. Purge will always keep at least 2 versions so
// specifying a smaller keep value will have no effect.
func (res *Resource) Purge(keepExtra int) { //nolint:gocognit
res.Lock()
defer res.Unlock()
// If there is any blacklisted version within the resource, pause purging.
// In this case we may need extra available versions beyond what would be
// available after purging.
for _, rv := range res.Versions {
if rv.Blacklisted {
log.Debugf(
"%s: pausing purging of resource %s, as it contains blacklisted items",
res.registry.Name,
rv.resource.Identifier,
)
return
}
}
// Safeguard the amount of extra version to keep.
if keepExtra < 2 {
keepExtra = 2
}
// Search for purge boundary.
var purgeBoundary int
var skippedActiveVersion bool
var skippedSelectedVersion bool
var skippedStableVersion bool
boundarySearch:
for i, rv := range res.Versions {
// Check if required versions are already skipped.
switch {
case !skippedActiveVersion && res.ActiveVersion != nil:
// Skip versions until the active version, if it's set.
case !skippedSelectedVersion && res.SelectedVersion != nil:
// Skip versions until the selected version, if it's set.
case !skippedStableVersion:
// Skip versions until the stable version.
default:
// All required version skipped, set purge boundary.
purgeBoundary = i + keepExtra
break boundarySearch
}
// Check if current instance is a required version.
if rv == res.ActiveVersion {
skippedActiveVersion = true
}
if rv == res.SelectedVersion {
skippedSelectedVersion = true
}
if !rv.PreRelease {
skippedStableVersion = true
}
}
// Check if there is anything to purge at all.
if purgeBoundary <= keepExtra || purgeBoundary >= len(res.Versions) {
return
}
// Purge everything beyond the purge boundary.
for _, rv := range res.Versions[purgeBoundary:] {
// Only remove if resource file is actually available.
if !rv.Available {
continue
}
// Remove resource file.
storagePath := rv.storagePath()
err := os.Remove(storagePath)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
log.Warningf("%s: failed to purge resource %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
}
} else {
log.Tracef("%s: purged resource %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
}
// Remove resource signature file.
err = os.Remove(rv.storageSigPath())
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
log.Warningf("%s: failed to purge resource signature %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
}
} else {
log.Tracef("%s: purged resource signature %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
}
// Remove unpacked version of resource.
ext := filepath.Ext(storagePath)
if ext == "" {
// Nothing to do if file does not have an extension.
continue
}
unpackedPath := strings.TrimSuffix(storagePath, ext)
// Remove if it exists, or an error occurs on access.
_, err = os.Stat(unpackedPath)
if err == nil || !errors.Is(err, fs.ErrNotExist) {
err = os.Remove(unpackedPath)
if err != nil {
log.Warningf("%s: failed to purge unpacked resource %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
} else {
log.Tracef("%s: purged unpacked resource %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
}
}
}
// remove entries of deleted files
res.Versions = res.Versions[purgeBoundary:]
}
// SigningMetadata returns the metadata to be included in signatures.
func (rv *ResourceVersion) SigningMetadata() map[string]string {
return map[string]string{
"id": rv.resource.Identifier,
"version": rv.VersionNumber,
}
}
// GetFile returns the version as a *File.
// It locks the resource for doing so.
func (rv *ResourceVersion) GetFile() *File {
rv.resource.Lock()
defer rv.resource.Unlock()
// check for notifier
if rv.resource.notifier == nil {
// create new notifier
rv.resource.notifier = newNotifier()
}
// create file
return &File{
resource: rv.resource,
version: rv,
notifier: rv.resource.notifier,
versionedPath: rv.versionedPath(),
storagePath: rv.storagePath(),
}
}
// versionedPath returns the versioned identifier.
func (rv *ResourceVersion) versionedPath() string {
return GetVersionedPath(rv.resource.Identifier, rv.VersionNumber)
}
// versionedSigPath returns the versioned identifier of the file signature.
func (rv *ResourceVersion) versionedSigPath() string {
return GetVersionedPath(rv.resource.Identifier, rv.VersionNumber) + filesig.Extension
}
// storagePath returns the absolute storage path.
func (rv *ResourceVersion) storagePath() string {
return filepath.Join(rv.resource.registry.storageDir.Path, filepath.FromSlash(rv.versionedPath()))
}
// storageSigPath returns the absolute storage path of the file signature.
func (rv *ResourceVersion) storageSigPath() string {
return rv.storagePath() + filesig.Extension
}

View file

@ -0,0 +1,119 @@
package updater
import (
"fmt"
"testing"
semver "github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
)
func TestVersionSelection(t *testing.T) {
t.Parallel()
res := registry.newResource("test/a")
err := res.AddVersion("1.2.2", true, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("1.2.3", true, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("1.2.4-beta", true, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("1.2.4-staging", true, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("1.2.5", false, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("1.2.6-beta", false, false, false)
if err != nil {
t.Fatal(err)
}
err = res.AddVersion("0", true, false, false)
if err != nil {
t.Fatal(err)
}
registry.UsePreReleases = true
registry.DevMode = true
registry.Online = true
res.Index = &Index{AutoDownload: true}
res.selectVersion()
if res.SelectedVersion.VersionNumber != "0.0.0" {
t.Errorf("selected version should be 0.0.0, not %s", res.SelectedVersion.VersionNumber)
}
registry.DevMode = false
res.selectVersion()
if res.SelectedVersion.VersionNumber != "1.2.6-beta" {
t.Errorf("selected version should be 1.2.6-beta, not %s", res.SelectedVersion.VersionNumber)
}
registry.UsePreReleases = false
res.selectVersion()
if res.SelectedVersion.VersionNumber != "1.2.5" {
t.Errorf("selected version should be 1.2.5, not %s", res.SelectedVersion.VersionNumber)
}
registry.Online = false
res.selectVersion()
if res.SelectedVersion.VersionNumber != "1.2.3" {
t.Errorf("selected version should be 1.2.3, not %s", res.SelectedVersion.VersionNumber)
}
f123 := res.GetFile()
f123.markActiveWithLocking()
err = res.Blacklist("1.2.3")
if err != nil {
t.Fatal(err)
}
if res.SelectedVersion.VersionNumber != "1.2.2" {
t.Errorf("selected version should be 1.2.2, not %s", res.SelectedVersion.VersionNumber)
}
if !f123.UpgradeAvailable() {
t.Error("upgrade should be available (flag)")
}
select {
case <-f123.WaitForAvailableUpgrade():
default:
t.Error("upgrade should be available (chan)")
}
t.Logf("resource: %+v", res)
for _, rv := range res.Versions {
t.Logf("version %s: %+v", rv.VersionNumber, rv)
}
}
func TestVersionParsing(t *testing.T) {
t.Parallel()
assert.Equal(t, "1.2.3", parseVersion("1.2.3"))
assert.Equal(t, "1.2.0", parseVersion("1.2.0"))
assert.Equal(t, "0.2.0", parseVersion("0.2.0"))
assert.Equal(t, "0.0.0", parseVersion("0"))
assert.Equal(t, "1.2.3-b", parseVersion("1.2.3-b"))
assert.Equal(t, "1.2.3-b", parseVersion("1.2.3b"))
assert.Equal(t, "1.2.3-beta", parseVersion("1.2.3-beta"))
assert.Equal(t, "1.2.3-beta", parseVersion("1.2.3beta"))
assert.Equal(t, "1.2.3", parseVersion("01.02.03"))
}
func parseVersion(v string) string {
sv, err := semver.NewVersion(v)
if err != nil {
return fmt.Sprintf("failed to parse version: %s", err)
}
return sv.String()
}

49
base/updater/signing.go Normal file
View file

@ -0,0 +1,49 @@
package updater
import (
"strings"
"github.com/safing/jess"
)
// VerificationOptions holds options for verification of files.
type VerificationOptions struct {
TrustStore jess.TrustStore
DownloadPolicy SignaturePolicy
DiskLoadPolicy SignaturePolicy
}
// GetVerificationOptions returns the verification options for the given identifier.
func (reg *ResourceRegistry) GetVerificationOptions(identifier string) *VerificationOptions {
if reg.Verification == nil {
return nil
}
var (
longestPrefix = -1
bestMatch *VerificationOptions
)
for prefix, opts := range reg.Verification {
if len(prefix) > longestPrefix && strings.HasPrefix(identifier, prefix) {
longestPrefix = len(prefix)
bestMatch = opts
}
}
return bestMatch
}
// SignaturePolicy defines behavior in case of errors.
type SignaturePolicy uint8
// Signature Policies.
const (
// SignaturePolicyRequire fails on any error.
SignaturePolicyRequire = iota
// SignaturePolicyWarn only warns on errors.
SignaturePolicyWarn
// SignaturePolicyDisable only downloads signatures, but does not verify them.
SignaturePolicyDisable
)

180
base/updater/state.go Normal file
View file

@ -0,0 +1,180 @@
package updater
import (
"sort"
"sync"
"time"
"github.com/safing/portmaster/base/utils"
)
// Registry States.
const (
StateReady = "ready" // Default idle state.
StateChecking = "checking" // Downloading indexes.
StateDownloading = "downloading" // Downloading updates.
StateFetching = "fetching" // Fetching a single file.
)
// RegistryState describes the registry state.
type RegistryState struct {
sync.Mutex
reg *ResourceRegistry
// ID holds the ID of the state the registry is currently in.
ID string
// Details holds further information about the current state.
Details any
// Updates holds generic information about the current status of pending
// and recently downloaded updates.
Updates UpdateState
// operationLock locks the operation of any state changing operation.
// This is separate from the registry lock, which locks access to the
// registry struct.
operationLock sync.Mutex
}
// StateDownloadingDetails holds details of the downloading state.
type StateDownloadingDetails struct {
// Resources holds the resource IDs that are being downloaded.
Resources []string
// FinishedUpTo holds the index of Resources that is currently being
// downloaded. Previous resources have finished downloading.
FinishedUpTo int
}
// UpdateState holds generic information about the current status of pending
// and recently downloaded updates.
type UpdateState struct {
// LastCheckAt holds the time of the last update check.
LastCheckAt *time.Time
// LastCheckError holds the error of the last check.
LastCheckError error
// PendingDownload holds the resources that are pending download.
PendingDownload []string
// LastDownloadAt holds the time when resources were downloaded the last time.
LastDownloadAt *time.Time
// LastDownloadError holds the error of the last download.
LastDownloadError error
// LastDownload holds the resources that we downloaded the last time updates
// were downloaded.
LastDownload []string
// LastSuccessAt holds the time of the last successful update (check).
LastSuccessAt *time.Time
}
// GetState returns the current registry state.
// The returned data must not be modified.
func (reg *ResourceRegistry) GetState() RegistryState {
reg.state.Lock()
defer reg.state.Unlock()
return RegistryState{
ID: reg.state.ID,
Details: reg.state.Details,
Updates: reg.state.Updates,
}
}
// StartOperation starts an operation.
func (s *RegistryState) StartOperation(id string) bool {
defer s.notify()
s.operationLock.Lock()
s.Lock()
defer s.Unlock()
s.ID = id
return true
}
// UpdateOperationDetails updates the details of an operation.
// The supplied struct should be a copy and must not be changed after calling
// this function.
func (s *RegistryState) UpdateOperationDetails(details any) {
defer s.notify()
s.Lock()
defer s.Unlock()
s.Details = details
}
// EndOperation ends an operation.
func (s *RegistryState) EndOperation() {
defer s.notify()
defer s.operationLock.Unlock()
s.Lock()
defer s.Unlock()
s.ID = StateReady
s.Details = nil
}
// ReportUpdateCheck reports an update check to the registry state.
func (s *RegistryState) ReportUpdateCheck(pendingDownload []string, failed error) {
defer s.notify()
sort.Strings(pendingDownload)
s.Lock()
defer s.Unlock()
now := time.Now()
s.Updates.LastCheckAt = &now
s.Updates.LastCheckError = failed
s.Updates.PendingDownload = pendingDownload
if failed == nil {
s.Updates.LastSuccessAt = &now
}
}
// ReportDownloads reports downloaded updates to the registry state.
func (s *RegistryState) ReportDownloads(downloaded []string, failed error) {
defer s.notify()
sort.Strings(downloaded)
s.Lock()
defer s.Unlock()
now := time.Now()
s.Updates.LastDownloadAt = &now
s.Updates.LastDownloadError = failed
s.Updates.LastDownload = downloaded
// Remove downloaded resources from the pending list.
if len(s.Updates.PendingDownload) > 0 {
newPendingDownload := make([]string, 0, len(s.Updates.PendingDownload))
for _, pending := range s.Updates.PendingDownload {
if !utils.StringInSlice(downloaded, pending) {
newPendingDownload = append(newPendingDownload, pending)
}
}
s.Updates.PendingDownload = newPendingDownload
}
if failed == nil {
s.Updates.LastSuccessAt = &now
}
}
func (s *RegistryState) notify() {
switch {
case s.reg == nil:
return
case s.reg.StateNotifyFunc == nil:
return
}
s.reg.StateNotifyFunc(s)
}

272
base/updater/storage.go Normal file
View file

@ -0,0 +1,272 @@
package updater
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/safing/jess/filesig"
"github.com/safing/jess/lhash"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
// ScanStorage scans root within the storage dir and adds found
// resources to the registry. If an error occurred, it is logged
// and the last error is returned. Everything that was found
// despite errors is added to the registry anyway. Leave root
// empty to scan the full storage dir.
func (reg *ResourceRegistry) ScanStorage(root string) error {
var lastError error
// prep root
if root == "" {
root = reg.storageDir.Path
} else {
var err error
root, err = filepath.Abs(root)
if err != nil {
return err
}
if !strings.HasPrefix(root, reg.storageDir.Path) {
return errors.New("supplied scan root path not within storage")
}
}
// walk fs
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
// skip tmp dir (including errors trying to read it)
if strings.HasPrefix(path, reg.tmpDir.Path) {
return filepath.SkipDir
}
// handle walker error
if err != nil {
lastError = fmt.Errorf("%s: could not read %s: %w", reg.Name, path, err)
log.Warning(lastError.Error())
return nil
}
// Ignore file signatures.
if strings.HasSuffix(path, filesig.Extension) {
return nil
}
// get relative path to storage
relativePath, err := filepath.Rel(reg.storageDir.Path, path)
if err != nil {
lastError = fmt.Errorf("%s: could not get relative path of %s: %w", reg.Name, path, err)
log.Warning(lastError.Error())
return nil
}
// convert to identifier and version
relativePath = filepath.ToSlash(relativePath)
identifier, version, ok := GetIdentifierAndVersion(relativePath)
if !ok {
// file does not conform to format
return nil
}
// fully ignore directories that also have an identifier - these will be unpacked resources
if info.IsDir() {
return filepath.SkipDir
}
// save
err = reg.AddResource(identifier, version, nil, true, false, false)
if err != nil {
lastError = fmt.Errorf("%s: could not get add resource %s v%s: %w", reg.Name, identifier, version, err)
log.Warning(lastError.Error())
}
return nil
})
return lastError
}
// LoadIndexes loads the current release indexes from disk
// or will fetch a new version if not available and the
// registry is marked as online.
func (reg *ResourceRegistry) LoadIndexes(ctx context.Context) error {
var firstErr error
client := &http.Client{}
for _, idx := range reg.getIndexes() {
err := reg.loadIndexFile(idx)
if err == nil {
log.Debugf("%s: loaded index %s", reg.Name, idx.Path)
} else if reg.Online {
// try to download the index file if a local disk version
// does not exist or we don't have permission to read it.
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrPermission) {
err = reg.downloadIndex(ctx, client, idx)
}
}
if err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// getIndexes returns a copy of the index.
// The indexes itself are references.
func (reg *ResourceRegistry) getIndexes() []*Index {
reg.RLock()
defer reg.RUnlock()
indexes := make([]*Index, len(reg.indexes))
copy(indexes, reg.indexes)
return indexes
}
func (reg *ResourceRegistry) loadIndexFile(idx *Index) error {
indexPath := filepath.Join(reg.storageDir.Path, filepath.FromSlash(idx.Path))
indexData, err := os.ReadFile(indexPath)
if err != nil {
return fmt.Errorf("failed to read index file %s: %w", idx.Path, err)
}
// Verify signature, if enabled.
if verifOpts := reg.GetVerificationOptions(idx.Path); verifOpts != nil {
// Load and check signature.
verifiedHash, _, err := reg.loadAndVerifySigFile(verifOpts, indexPath+filesig.Extension)
if err != nil {
switch verifOpts.DiskLoadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("failed to verify signature of index %s: %w", idx.Path, err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify signature of index %s: %s", reg.Name, idx.Path, err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify signature of index %s: %s", reg.Name, idx.Path, err)
}
}
// Check if signature checksum matches the index data.
if err == nil && !verifiedHash.Matches(indexData) {
switch verifOpts.DiskLoadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("index file %s does not match signature", idx.Path)
case SignaturePolicyWarn:
log.Warningf("%s: index file %s does not match signature", reg.Name, idx.Path)
case SignaturePolicyDisable:
log.Debugf("%s: index file %s does not match signature", reg.Name, idx.Path)
}
}
}
// Parse the index file.
indexFile, err := ParseIndexFile(indexData, idx.Channel, idx.LastRelease)
if err != nil {
return fmt.Errorf("failed to parse index file %s: %w", idx.Path, err)
}
// Update last seen release.
idx.LastRelease = indexFile.Published
// Warn if there aren't any releases in the index.
if len(indexFile.Releases) == 0 {
log.Debugf("%s: index %s has no releases", reg.Name, idx.Path)
return nil
}
// Add index releases to available resources.
err = reg.AddResources(indexFile.Releases, idx, false, true, idx.PreRelease)
if err != nil {
log.Warningf("%s: failed to add resource: %s", reg.Name, err)
}
return nil
}
func (reg *ResourceRegistry) loadAndVerifySigFile(verifOpts *VerificationOptions, sigFilePath string) (*lhash.LabeledHash, []byte, error) {
// Load signature file.
sigFileData, err := os.ReadFile(sigFilePath)
if err != nil {
return nil, nil, fmt.Errorf("failed to read signature file: %w", err)
}
// Extract all signatures.
sigs, err := filesig.ParseSigFile(sigFileData)
switch {
case len(sigs) == 0 && err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
case len(sigs) == 0:
return nil, nil, errors.New("no signatures found in signature file")
case err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
}
// Verify all signatures.
var verifiedHash *lhash.LabeledHash
for _, sig := range sigs {
fd, err := filesig.VerifyFileData(
sig,
nil,
verifOpts.TrustStore,
)
if err != nil {
return nil, sigFileData, err
}
// Save or check verified hash.
if verifiedHash == nil {
verifiedHash = fd.FileHash()
} else if !fd.FileHash().Equal(verifiedHash) {
// Return an error if two valid hashes mismatch.
// For simplicity, all hash algorithms must be the same for now.
return nil, sigFileData, errors.New("file hashes from different signatures do not match")
}
}
return verifiedHash, sigFileData, nil
}
// CreateSymlinks creates a directory structure with unversioned symlinks to the given updates list.
func (reg *ResourceRegistry) CreateSymlinks(symlinkRoot *utils.DirStructure) error {
err := os.RemoveAll(symlinkRoot.Path)
if err != nil {
return fmt.Errorf("failed to wipe symlink root: %w", err)
}
err = symlinkRoot.Ensure()
if err != nil {
return fmt.Errorf("failed to create symlink root: %w", err)
}
reg.RLock()
defer reg.RUnlock()
for _, res := range reg.resources {
if res.SelectedVersion == nil {
return fmt.Errorf("no selected version available for %s", res.Identifier)
}
targetPath := res.SelectedVersion.storagePath()
linkPath := filepath.Join(symlinkRoot.Path, filepath.FromSlash(res.Identifier))
linkPathDir := filepath.Dir(linkPath)
err = symlinkRoot.EnsureAbsPath(linkPathDir)
if err != nil {
return fmt.Errorf("failed to create dir for link: %w", err)
}
relativeTargetPath, err := filepath.Rel(linkPathDir, targetPath)
if err != nil {
return fmt.Errorf("failed to get relative target path: %w", err)
}
err = os.Symlink(relativeTargetPath, linkPath)
if err != nil {
return fmt.Errorf("failed to link %s: %w", res.Identifier, err)
}
}
return nil
}

View file

@ -0,0 +1,68 @@
package updater
/*
func testLoadLatestScope(t *testing.T, basePath, filePath, expectedIdentifier, expectedVersion string) {
fullPath := filepath.Join(basePath, filePath)
// create dir
dirPath := filepath.Dir(fullPath)
err := os.MkdirAll(dirPath, 0755)
if err != nil {
t.Fatalf("could not create test dir: %s\n", err)
return
}
// touch file
err = os.WriteFile(fullPath, []byte{}, 0644)
if err != nil {
t.Fatalf("could not create test file: %s\n", err)
return
}
// run loadLatestScope
latest, err := ScanForLatest(basePath, true)
if err != nil {
t.Errorf("could not update latest: %s\n", err)
return
}
for key, val := range latest {
localUpdates[key] = val
}
// test result
version, ok := localUpdates[expectedIdentifier]
if !ok {
t.Errorf("identifier %s not in map", expectedIdentifier)
t.Errorf("current map: %v", localUpdates)
}
if version != expectedVersion {
t.Errorf("unexpected version for %s: %s", filePath, version)
}
}
func TestLoadLatestScope(t *testing.T) {
updatesLock.Lock()
defer updatesLock.Unlock()
tmpDir, err := os.MkdirTemp("", "testing_")
if err != nil {
t.Fatalf("could not create test dir: %s\n", err)
return
}
defer os.RemoveAll(tmpDir)
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-3.zip", "all/ui/assets.zip", "1.2.3")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-4b.zip", "all/ui/assets.zip", "1.2.4b")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-5.zip", "all/ui/assets.zip", "1.2.5")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-3-4.zip", "all/ui/assets.zip", "1.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v2-3-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-3.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-2-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "all/ui/assets_v1-3-4.zip", "all/ui/assets.zip", "2.3.4")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v1-2-3", "os_platform/portmaster/portmaster", "1.2.3")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v2-1-1", "os_platform/portmaster/portmaster", "2.1.1")
testLoadLatestScope(t, tmpDir, "os_platform/portmaster/portmaster_v1-2-3", "os_platform/portmaster/portmaster", "2.1.1")
}
*/

195
base/updater/unpacking.go Normal file
View file

@ -0,0 +1,195 @@
package updater
import (
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
// MaxUnpackSize specifies the maximum size that will be unpacked.
const MaxUnpackSize = 1000000000 // 1GB
// UnpackGZIP unpacks a GZIP compressed reader r
// and returns a new reader. It's suitable to be
// used with registry.GetPackedFile.
func UnpackGZIP(r io.Reader) (io.Reader, error) {
return gzip.NewReader(r)
}
// UnpackResources unpacks all resources defined in the AutoUnpack list.
func (reg *ResourceRegistry) UnpackResources() error {
reg.RLock()
defer reg.RUnlock()
var multierr *multierror.Error
for _, res := range reg.resources {
if utils.StringInSlice(reg.AutoUnpack, res.Identifier) {
err := res.UnpackArchive()
if err != nil {
multierr = multierror.Append(
multierr,
fmt.Errorf("%s: %w", res.Identifier, err),
)
}
}
}
return multierr.ErrorOrNil()
}
const (
zipSuffix = ".zip"
)
// UnpackArchive unpacks the archive the resource refers to. The contents are
// unpacked into a directory with the same name as the file, excluding the
// suffix. If the destination folder already exists, it is assumed that the
// contents have already been correctly unpacked.
func (res *Resource) UnpackArchive() error {
res.Lock()
defer res.Unlock()
// Only unpack selected versions.
if res.SelectedVersion == nil {
return nil
}
switch {
case strings.HasSuffix(res.Identifier, zipSuffix):
return res.unpackZipArchive()
default:
return fmt.Errorf("unsupported file type for unpacking")
}
}
func (res *Resource) unpackZipArchive() error {
// Get file and directory paths.
archiveFile := res.SelectedVersion.storagePath()
destDir := strings.TrimSuffix(archiveFile, zipSuffix)
tmpDir := filepath.Join(
res.registry.tmpDir.Path,
filepath.FromSlash(strings.TrimSuffix(
path.Base(res.SelectedVersion.versionedPath()),
zipSuffix,
)),
)
// Check status of destination.
dstStat, err := os.Stat(destDir)
switch {
case errors.Is(err, fs.ErrNotExist):
// The destination does not exist, continue with unpacking.
case err != nil:
return fmt.Errorf("cannot access destination for unpacking: %w", err)
case !dstStat.IsDir():
return fmt.Errorf("destination for unpacking is blocked by file: %s", dstStat.Name())
default:
// Archive already seems to be unpacked.
return nil
}
// Create the tmp directory for unpacking.
err = res.registry.tmpDir.EnsureAbsPath(tmpDir)
if err != nil {
return fmt.Errorf("failed to create tmp dir for unpacking: %w", err)
}
// Defer clean up of directories.
defer func() {
// Always clean up the tmp dir.
_ = os.RemoveAll(tmpDir)
// Cleanup the destination in case of an error.
if err != nil {
_ = os.RemoveAll(destDir)
}
}()
// Open the archive for reading.
var archiveReader *zip.ReadCloser
archiveReader, err = zip.OpenReader(archiveFile)
if err != nil {
return fmt.Errorf("failed to open zip reader: %w", err)
}
defer func() {
_ = archiveReader.Close()
}()
// Save all files to the tmp dir.
for _, file := range archiveReader.File {
err = copyFromZipArchive(
file,
filepath.Join(tmpDir, filepath.FromSlash(file.Name)),
)
if err != nil {
return fmt.Errorf("failed to extract archive file %s: %w", file.Name, err)
}
}
// Make the final move.
err = os.Rename(tmpDir, destDir)
if err != nil {
return fmt.Errorf("failed to move the extracted archive from %s to %s: %w", tmpDir, destDir, err)
}
// Fix permissions on the destination dir.
err = res.registry.storageDir.EnsureAbsPath(destDir)
if err != nil {
return fmt.Errorf("failed to apply directory permissions on %s: %w", destDir, err)
}
log.Infof("%s: unpacked %s", res.registry.Name, res.SelectedVersion.versionedPath())
return nil
}
func copyFromZipArchive(archiveFile *zip.File, dstPath string) error {
// If file is a directory, create it and continue.
if archiveFile.FileInfo().IsDir() {
err := os.Mkdir(dstPath, archiveFile.Mode())
if err != nil {
return fmt.Errorf("failed to create directory %s: %w", dstPath, err)
}
return nil
}
// Open archived file for reading.
fileReader, err := archiveFile.Open()
if err != nil {
return fmt.Errorf("failed to open file in archive: %w", err)
}
defer func() {
_ = fileReader.Close()
}()
// Open destination file for writing.
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveFile.Mode())
if err != nil {
return fmt.Errorf("failed to open destination file %s: %w", dstPath, err)
}
defer func() {
_ = dstFile.Close()
}()
// Copy full file from archive to dst.
if _, err := io.CopyN(dstFile, fileReader, MaxUnpackSize); err != nil {
// EOF is expected here as the archive is likely smaller
// thane MaxUnpackSize
if errors.Is(err, io.EOF) {
return nil
}
return err
}
return nil
}

359
base/updater/updating.go Normal file
View file

@ -0,0 +1,359 @@
package updater
import (
"context"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"golang.org/x/exp/slices"
"github.com/safing/jess/filesig"
"github.com/safing/jess/lhash"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/utils"
)
// UpdateIndexes downloads all indexes. An error is only returned when all
// indexes fail to update.
func (reg *ResourceRegistry) UpdateIndexes(ctx context.Context) error {
var lastErr error
var anySuccess bool
// Start registry operation.
reg.state.StartOperation(StateChecking)
defer reg.state.EndOperation()
client := &http.Client{}
for _, idx := range reg.getIndexes() {
if err := reg.downloadIndex(ctx, client, idx); err != nil {
lastErr = err
log.Warningf("%s: failed to update index %s: %s", reg.Name, idx.Path, err)
} else {
anySuccess = true
}
}
// If all indexes failed to update, fail.
if !anySuccess {
err := fmt.Errorf("failed to update all indexes, last error was: %w", lastErr)
reg.state.ReportUpdateCheck(nil, err)
return err
}
// Get pending resources and update status.
pendingResourceVersions, _ := reg.GetPendingDownloads(true, false)
reg.state.ReportUpdateCheck(
humanInfoFromResourceVersions(pendingResourceVersions),
nil,
)
return nil
}
func (reg *ResourceRegistry) downloadIndex(ctx context.Context, client *http.Client, idx *Index) error {
var (
// Index.
indexErr error
indexData []byte
downloadURL string
// Signature.
sigErr error
verifiedHash *lhash.LabeledHash
sigFileData []byte
verifOpts = reg.GetVerificationOptions(idx.Path)
)
// Upgrade to v2 index if verification is enabled.
downloadIndexPath := idx.Path
if verifOpts != nil {
downloadIndexPath = strings.TrimSuffix(downloadIndexPath, baseIndexExtension) + v2IndexExtension
}
// Download new index and signature.
for tries := range 3 {
// Index and signature need to be fetched together, so that they are
// fetched from the same source. One source should always have a matching
// index and signature. Backup sources may be behind a little.
// If the signature verification fails, another source should be tried.
// Get index data.
indexData, downloadURL, indexErr = reg.fetchData(ctx, client, downloadIndexPath, tries)
if indexErr != nil {
log.Debugf("%s: failed to fetch index %s: %s", reg.Name, downloadURL, indexErr)
continue
}
// Get signature and verify it.
if verifOpts != nil {
verifiedHash, sigFileData, sigErr = reg.fetchAndVerifySigFile(
ctx, client,
verifOpts, downloadIndexPath+filesig.Extension, nil,
tries,
)
if sigErr != nil {
log.Debugf("%s: failed to verify signature of %s: %s", reg.Name, downloadURL, sigErr)
continue
}
// Check if the index matches the verified hash.
if verifiedHash.Matches(indexData) {
log.Infof("%s: verified signature of %s", reg.Name, downloadURL)
} else {
sigErr = ErrIndexChecksumMismatch
log.Debugf("%s: checksum does not match file from %s", reg.Name, downloadURL)
continue
}
}
break
}
if indexErr != nil {
return fmt.Errorf("failed to fetch index %s: %w", downloadIndexPath, indexErr)
}
if sigErr != nil {
return fmt.Errorf("failed to fetch or verify index %s signature: %w", downloadIndexPath, sigErr)
}
// Parse the index file.
indexFile, err := ParseIndexFile(indexData, idx.Channel, idx.LastRelease)
if err != nil {
return fmt.Errorf("failed to parse index %s: %w", idx.Path, err)
}
// Add index data to registry.
if len(indexFile.Releases) > 0 {
// Check if all resources are within the indexes' authority.
authoritativePath := path.Dir(idx.Path) + "/"
if authoritativePath == "./" {
// Fix path for indexes at the storage root.
authoritativePath = ""
}
cleanedData := make(map[string]string, len(indexFile.Releases))
for key, version := range indexFile.Releases {
if strings.HasPrefix(key, authoritativePath) {
cleanedData[key] = version
} else {
log.Warningf("%s: index %s oversteps it's authority by defining version for %s", reg.Name, idx.Path, key)
}
}
// add resources to registry
err = reg.AddResources(cleanedData, idx, false, true, idx.PreRelease)
if err != nil {
log.Warningf("%s: failed to add resources: %s", reg.Name, err)
}
} else {
log.Debugf("%s: index %s is empty", reg.Name, idx.Path)
}
// Check if dest dir exists.
indexDir := filepath.FromSlash(path.Dir(idx.Path))
err = reg.storageDir.EnsureRelPath(indexDir)
if err != nil {
log.Warningf("%s: failed to ensure directory for updated index %s: %s", reg.Name, idx.Path, err)
}
// Index files must be readable by portmaster-staert with user permissions in order to load the index.
err = os.WriteFile( //nolint:gosec
filepath.Join(reg.storageDir.Path, filepath.FromSlash(idx.Path)),
indexData, 0o0644,
)
if err != nil {
log.Warningf("%s: failed to save updated index %s: %s", reg.Name, idx.Path, err)
}
// Write signature file, if we have one.
if len(sigFileData) > 0 {
err = os.WriteFile( //nolint:gosec
filepath.Join(reg.storageDir.Path, filepath.FromSlash(idx.Path)+filesig.Extension),
sigFileData, 0o0644,
)
if err != nil {
log.Warningf("%s: failed to save updated index signature %s: %s", reg.Name, idx.Path+filesig.Extension, err)
}
}
log.Infof("%s: updated index %s with %d entries", reg.Name, idx.Path, len(indexFile.Releases))
return nil
}
// DownloadUpdates checks if updates are available and downloads updates of used components.
func (reg *ResourceRegistry) DownloadUpdates(ctx context.Context, includeManual bool) error {
// Start registry operation.
reg.state.StartOperation(StateDownloading)
defer reg.state.EndOperation()
// Get pending updates.
toUpdate, missingSigs := reg.GetPendingDownloads(includeManual, true)
downloadDetailsResources := humanInfoFromResourceVersions(toUpdate)
reg.state.UpdateOperationDetails(&StateDownloadingDetails{
Resources: downloadDetailsResources,
})
// nothing to update
if len(toUpdate) == 0 && len(missingSigs) == 0 {
log.Infof("%s: everything up to date", reg.Name)
return nil
}
// check download dir
if err := reg.tmpDir.Ensure(); err != nil {
return fmt.Errorf("could not prepare tmp directory for download: %w", err)
}
// download updates
log.Infof("%s: starting to download %d updates", reg.Name, len(toUpdate))
client := &http.Client{}
var reportError error
for i, rv := range toUpdate {
log.Infof(
"%s: downloading update [%d/%d]: %s version %s",
reg.Name,
i+1, len(toUpdate),
rv.resource.Identifier, rv.VersionNumber,
)
var err error
for tries := range 3 {
err = reg.fetchFile(ctx, client, rv, tries)
if err == nil {
// Update resource version state.
rv.resource.Lock()
rv.Available = true
if rv.resource.VerificationOptions != nil {
rv.SigAvailable = true
}
rv.resource.Unlock()
break
}
}
if err != nil {
reportError := fmt.Errorf("failed to download %s version %s: %w", rv.resource.Identifier, rv.VersionNumber, err)
log.Warningf("%s: %s", reg.Name, reportError)
}
reg.state.UpdateOperationDetails(&StateDownloadingDetails{
Resources: downloadDetailsResources,
FinishedUpTo: i + 1,
})
}
if len(missingSigs) > 0 {
log.Infof("%s: downloading %d missing signatures", reg.Name, len(missingSigs))
for _, rv := range missingSigs {
var err error
for tries := range 3 {
err = reg.fetchMissingSig(ctx, client, rv, tries)
if err == nil {
// Update resource version state.
rv.resource.Lock()
rv.SigAvailable = true
rv.resource.Unlock()
break
}
}
if err != nil {
reportError := fmt.Errorf("failed to download missing sig of %s version %s: %w", rv.resource.Identifier, rv.VersionNumber, err)
log.Warningf("%s: %s", reg.Name, reportError)
}
}
}
reg.state.ReportDownloads(
downloadDetailsResources,
reportError,
)
log.Infof("%s: finished downloading updates", reg.Name)
return nil
}
// DownloadUpdates checks if updates are available and downloads updates of used components.
// GetPendingDownloads returns the list of pending downloads.
// If manual is set, indexes with AutoDownload=false will be checked.
// If auto is set, indexes with AutoDownload=true will be checked.
func (reg *ResourceRegistry) GetPendingDownloads(manual, auto bool) (resources, sigs []*ResourceVersion) {
reg.RLock()
defer reg.RUnlock()
// create list of downloads
var toUpdate []*ResourceVersion
var missingSigs []*ResourceVersion
for _, res := range reg.resources {
func() {
res.Lock()
defer res.Unlock()
// Skip resources without index or indexes that should not be reported
// according to parameters.
switch {
case res.Index == nil:
// Cannot download if resource is not part of an index.
return
case manual && !res.Index.AutoDownload:
// Manual update report and index is not auto-download.
case auto && res.Index.AutoDownload:
// Auto update report and index is auto-download.
default:
// Resource should not be reported.
return
}
// Skip resources we don't need.
switch {
case res.inUse():
// Update if resource is in use.
case res.available():
// Update if resource is available locally, ie. was used in the past.
case utils.StringInSlice(reg.MandatoryUpdates, res.Identifier):
// Update is set as mandatory.
default:
// Resource does not need to be updated.
return
}
// Go through all versions until we find versions that need updating.
for _, rv := range res.Versions {
switch {
case !rv.CurrentRelease:
// We are not interested in older releases.
case !rv.Available:
// File not available locally, download!
toUpdate = append(toUpdate, rv)
case !rv.SigAvailable && res.VerificationOptions != nil:
// File signature is not available and verification is enabled, download signature!
missingSigs = append(missingSigs, rv)
}
}
}()
}
slices.SortFunc(toUpdate, func(a, b *ResourceVersion) int {
return strings.Compare(a.resource.Identifier, b.resource.Identifier)
})
slices.SortFunc(missingSigs, func(a, b *ResourceVersion) int {
return strings.Compare(a.resource.Identifier, b.resource.Identifier)
})
return toUpdate, missingSigs
}
func humanInfoFromResourceVersions(resourceVersions []*ResourceVersion) []string {
identifiers := make([]string, len(resourceVersions))
for i, rv := range resourceVersions {
identifiers[i] = fmt.Sprintf("%s v%s", rv.resource.Identifier, rv.VersionNumber)
}
return identifiers
}

View file

@ -18,13 +18,13 @@ import (
"github.com/safing/portmaster/base/metrics"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/service/updates"
"github.com/safing/portmaster/service/updates/helper"
"github.com/safing/portmaster/spn"
"github.com/safing/portmaster/spn/conf"
)
func init() {
// flag.BoolVar(&updates.RebootOnRestart, "reboot-on-restart", false, "reboot server on auto-upgrade")
// FIXME
flag.BoolVar(&updates.RebootOnRestart, "reboot-on-restart", false, "reboot server on auto-upgrade")
}
var sigUSR1 = syscall.Signal(0xa)
@ -40,15 +40,14 @@ func main() {
// Configure user agent and updates.
updates.UserAgent = fmt.Sprintf("SPN Hub (%s %s)", runtime.GOOS, runtime.GOARCH)
// helper.IntelOnly()
helper.IntelOnly()
// Set SPN public hub mode.
conf.EnablePublicHub(true)
// Start logger with default log level.
_ = log.Start(log.WarningLevel)
// FIXME: Use service?
// Set default log level.
log.SetLogLevel(log.WarningLevel)
_ = log.Start()
// Create instance.
var execCmdLine bool
@ -111,7 +110,7 @@ func main() {
slog.Warn("program was interrupted, stopping")
}
case <-instance.ShutdownComplete():
case <-instance.Stopped():
log.Shutdown()
os.Exit(instance.ExitCode())
}

34
cmds/notifier/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# Compiled binaries
notifier
notifier.exe
# Go vendor
vendor
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
*.prof
# Output of the go coverage tool, specifically when used with LiteIDE
*.out

5
cmds/notifier/README.md Normal file
View file

@ -0,0 +1,5 @@
### Development Dependencies
sudo apt install libgtk-3-dev libayatana-appindicator3-dev libwebkitgtk-3.0-dev libgl1-mesa-dev libglu1-mesa-dev libnotify-dev libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
sudo pacman -S libappindicator-gtk3

63
cmds/notifier/http_api.go Normal file
View file

@ -0,0 +1,63 @@
package main
import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
"github.com/safing/portmaster/base/log"
)
const (
apiBaseURL = "http://127.0.0.1:817/api/v1/"
apiShutdownEndpoint = "core/shutdown"
)
var httpAPIClient *http.Client
func init() {
// Make cookie jar.
jar, err := cookiejar.New(nil)
if err != nil {
log.Warningf("http-api: failed to create cookie jar: %s", err)
jar = nil
}
// Create client.
httpAPIClient = &http.Client{
Jar: jar,
Timeout: 3 * time.Second,
}
}
func httpAPIAction(endpoint string) (response string, err error) {
// Make action request.
resp, err := httpAPIClient.Post(apiBaseURL+endpoint, "", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
// Read the response body.
defer func() { _ = resp.Body.Close() }()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read data: %w", err)
}
response = strings.TrimSpace(string(respData))
// Check if the request was successful on the server.
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return response, fmt.Errorf("server failed with %s: %s", resp.Status, response)
}
return response, nil
}
// TriggerShutdown triggers a shutdown via the APi.
func TriggerShutdown() error {
_, err := httpAPIAction(apiShutdownEndpoint)
return err
}

25
cmds/notifier/icons.go Normal file
View file

@ -0,0 +1,25 @@
package main
import (
"os"
"path/filepath"
"sync"
icons "github.com/safing/portmaster/assets"
)
var (
appIconEnsureOnce sync.Once
appIconPath string
)
func ensureAppIcon() (location string, err error) {
appIconEnsureOnce.Do(func() {
if appIconPath == "" {
appIconPath = filepath.Join(dataDir, "exec", "portmaster.png")
}
err = os.WriteFile(appIconPath, icons.PNG, 0o0644) // nolint:gosec
})
return appIconPath, err
}

287
cmds/notifier/main.go Normal file
View file

@ -0,0 +1,287 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/pprof"
"strings"
"sync"
"syscall"
"time"
"github.com/tevino/abool"
"github.com/safing/portmaster/base/api/client"
"github.com/safing/portmaster/base/dataroot"
"github.com/safing/portmaster/base/info"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/updater"
"github.com/safing/portmaster/base/utils"
"github.com/safing/portmaster/service/updates/helper"
)
var (
dataDir string
printStackOnExit bool
showVersion bool
apiClient = client.NewClient("127.0.0.1:817")
connected = abool.New()
shuttingDown = abool.New()
restarting = abool.New()
mainCtx, cancelMainCtx = context.WithCancel(context.Background())
mainWg = &sync.WaitGroup{}
dataRoot *utils.DirStructure
// Create registry.
registry = &updater.ResourceRegistry{
Name: "updates",
UpdateURLs: []string{
"https://updates.safing.io",
},
DevMode: false,
Online: false, // disable download of resources (this is job for the core).
}
)
const query = "query "
func init() {
flag.StringVar(&dataDir, "data", "", "set data directory")
flag.BoolVar(&printStackOnExit, "print-stack-on-exit", false, "prints the stack before of shutting down")
flag.BoolVar(&showVersion, "version", false, "show version and exit")
runtime.GOMAXPROCS(2)
}
func main() {
// parse flags
flag.Parse()
// set meta info
info.Set("Portmaster Notifier", "0.3.6", "GPLv3")
// check if meta info is ok
err := info.CheckVersion()
if err != nil {
fmt.Println("compile error: please compile using the provided build script")
os.Exit(1)
}
// print help
// if modules.HelpFlag {
// flag.Usage()
// os.Exit(0)
// }
if showVersion {
fmt.Println(info.FullVersion())
os.Exit(0)
}
// auto detect
if dataDir == "" {
dataDir = detectDataDir()
}
// check data dir
if dataDir == "" {
fmt.Fprintln(os.Stderr, "please set the data directory using --data=/path/to/data/dir")
os.Exit(1)
}
// switch to safe exec dir
err = os.Chdir(filepath.Join(dataDir, "exec"))
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to switch to safe exec dir: %s\n", err)
}
// start log writer
err = log.Start()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to start logging: %s\n", err)
os.Exit(1)
}
// load registry
err = configureRegistry(true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load registry: %s\n", err)
os.Exit(1)
}
// connect to API
go apiClient.StayConnected()
go apiStatusMonitor()
// start subsystems
go tray()
go subsystemsClient()
go spnStatusClient()
go notifClient()
go startShutdownEventListener()
// Shutdown
// catch interrupt for clean shutdown
signalCh := make(chan os.Signal, 1)
signal.Notify(
signalCh,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
)
// wait for shutdown
select {
case <-signalCh:
fmt.Println(" <INTERRUPT>")
log.Warning("program was interrupted, shutting down")
case <-mainCtx.Done():
log.Warning("program is shutting down")
}
if printStackOnExit {
fmt.Println("=== PRINTING STACK ===")
_ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
fmt.Println("=== END STACK ===")
}
go func() {
time.Sleep(10 * time.Second)
fmt.Println("===== TAKING TOO LONG FOR SHUTDOWN - PRINTING STACK TRACES =====")
_ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 2)
os.Exit(1)
}()
// clear all notifications
clearNotifications()
// shutdown
cancelMainCtx()
mainWg.Wait()
apiClient.Shutdown()
exitTray()
log.Shutdown()
os.Exit(0)
}
func apiStatusMonitor() {
for {
// Wait for connection.
<-apiClient.Online()
connected.Set()
triggerTrayUpdate()
// Wait for lost connection.
<-apiClient.Offline()
connected.UnSet()
triggerTrayUpdate()
}
}
func detectDataDir() string {
// get path of executable
binPath, err := os.Executable()
if err != nil {
return ""
}
// get directory
binDir := filepath.Dir(binPath)
// check if we in the updates directory
identifierDir := filepath.Join("updates", runtime.GOOS+"_"+runtime.GOARCH, "notifier")
// check if there is a match and return data dir
if strings.HasSuffix(binDir, identifierDir) {
return filepath.Clean(strings.TrimSuffix(binDir, identifierDir))
}
return ""
}
func configureRegistry(mustLoadIndex bool) error {
// If dataDir is not set, check the environment variable.
if dataDir == "" {
dataDir = os.Getenv("PORTMASTER_DATA")
}
// If it's still empty, try to auto-detect it.
if dataDir == "" {
dataDir = detectInstallationDir()
}
// Finally, if it's still empty, the user must provide it.
if dataDir == "" {
return errors.New("please set the data directory using --data=/path/to/data/dir")
}
// Remove left over quotes.
dataDir = strings.Trim(dataDir, `\"`)
// Initialize data root.
err := dataroot.Initialize(dataDir, 0o0755)
if err != nil {
return fmt.Errorf("failed to initialize data root: %w", err)
}
dataRoot = dataroot.Root()
// Initialize registry.
err = registry.Initialize(dataRoot.ChildDir("updates", 0o0755))
if err != nil {
return err
}
return updateRegistryIndex(mustLoadIndex)
}
func detectInstallationDir() string {
exePath, err := filepath.Abs(os.Args[0])
if err != nil {
return ""
}
parent := filepath.Dir(exePath) // parent should be "...\updates\windows_amd64\notifier"
stableJSONFile := filepath.Join(parent, "..", "..", "stable.json") // "...\updates\stable.json"
stat, err := os.Stat(stableJSONFile)
if err != nil {
return ""
}
if stat.IsDir() {
return ""
}
return parent
}
func updateRegistryIndex(mustLoadIndex bool) error {
// Set indexes based on the release channel.
warning := helper.SetIndexes(registry, "", false, false, false)
if warning != nil {
log.Warningf("%q", warning)
}
// Load indexes from disk or network, if needed and desired.
err := registry.LoadIndexes(context.Background())
if err != nil {
log.Warningf("error loading indexes %q", warning)
if mustLoadIndex {
return err
}
}
// Load versions from disk to know which others we have and which are available.
err = registry.ScanStorage("")
if err != nil {
log.Warningf("error during storage scan: %q\n", err)
}
registry.SelectVersions()
return nil
}

View file

@ -0,0 +1,35 @@
package main
import (
"fmt"
pbnotify "github.com/safing/portmaster/base/notifications"
)
// Notification represents a notification that is to be delivered to the user.
type Notification struct {
pbnotify.Notification
// systemID holds the ID returned by the dbus interface on Linux or by WinToast library on Windows.
systemID NotificationID
}
// IsSupportedAction returns whether the action is supported on this system.
func IsSupportedAction(a pbnotify.Action) bool {
switch a.Type {
case pbnotify.ActionTypeNone:
return true
default:
return false
}
}
// SelectAction sends an action back to the portmaster.
func (n *Notification) SelectAction(action string) {
upd := &pbnotify.Notification{
EventID: n.EventID,
SelectedActionID: action,
}
_ = apiClient.Update(fmt.Sprintf("%s%s", dbNotifBasePath, upd.EventID), upd, nil)
}

102
cmds/notifier/notify.go Normal file
View file

@ -0,0 +1,102 @@
package main
import (
"fmt"
"strings"
"sync"
"time"
"github.com/safing/portmaster/base/api/client"
"github.com/safing/portmaster/base/log"
pbnotify "github.com/safing/portmaster/base/notifications"
"github.com/safing/structures/dsd"
)
const (
dbNotifBasePath = "notifications:all/"
)
var (
notifications = make(map[string]*Notification)
notificationsLock sync.Mutex
)
func notifClient() {
notifOp := apiClient.Qsub(fmt.Sprintf("query %s where ShowOnSystem is true", dbNotifBasePath), handleNotification)
notifOp.EnableResuscitation()
// start the action listener and block
// until it's closed.
actionListener()
}
func handleNotification(m *client.Message) {
notificationsLock.Lock()
defer notificationsLock.Unlock()
log.Tracef("received %s msg: %s", m.Type, m.Key)
switch m.Type {
case client.MsgError:
case client.MsgDone:
case client.MsgSuccess:
case client.MsgOk, client.MsgUpdate, client.MsgNew:
n := &Notification{}
_, err := dsd.Load(m.RawValue, &n.Notification)
if err != nil {
log.Warningf("notify: failed to parse new notification: %s", err)
return
}
// copy existing system values
existing, ok := notifications[n.EventID]
if ok {
existing.Lock()
n.systemID = existing.systemID
existing.Unlock()
}
// save
notifications[n.EventID] = n
// Handle notification.
switch {
case existing != nil:
// Cancel existing notification if not active, else ignore.
if n.State != pbnotify.Active {
existing.Cancel()
}
return
case n.State == pbnotify.Active:
// Show new notifications that are active.
n.Show()
default:
// Ignore new notifications that are not active.
}
case client.MsgDelete:
n, ok := notifications[strings.TrimPrefix(m.Key, dbNotifBasePath)]
if ok {
n.Cancel()
delete(notifications, n.EventID)
}
case client.MsgWarning:
case client.MsgOffline:
}
}
func clearNotifications() {
notificationsLock.Lock()
defer notificationsLock.Unlock()
for _, n := range notifications {
n.Cancel()
}
// Wait for goroutines that cancel notifications.
// TODO: Revamp to use a waitgroup.
time.Sleep(1 * time.Second)
}

View file

@ -0,0 +1,160 @@
package main
import (
"context"
"errors"
"sync"
notify "github.com/dhaavi/go-notify"
"github.com/safing/portmaster/base/log"
)
type NotificationID uint32
var (
capabilities notify.Capabilities
notifsByID sync.Map
)
func init() {
var err error
capabilities, err = notify.GetCapabilities()
if err != nil {
log.Errorf("failed to get notification system capabilities: %s", err)
}
}
func handleActions(ctx context.Context, actions chan notify.Signal) {
mainWg.Add(1)
defer mainWg.Done()
listenForNotifications:
for {
select {
case <-ctx.Done():
return
case sig := <-actions:
if sig.Name != "org.freedesktop.Notifications.ActionInvoked" {
// we don't care for anything else (dismissed, closed)
continue listenForNotifications
}
// get notification by system ID
n, ok := notifsByID.LoadAndDelete(NotificationID(sig.ID))
if !ok {
continue listenForNotifications
}
notification, ok := n.(*Notification)
if !ok {
log.Errorf("received invalid notification type %T", n)
continue listenForNotifications
}
log.Tracef("notify: received signal: %+v", sig)
if sig.ActionKey != "" {
// send action
if ok {
notification.Lock()
notification.SelectAction(sig.ActionKey)
notification.Unlock()
}
} else {
log.Tracef("notify: notification clicked: %+v", sig)
// Global action invoked, start the app
launchApp()
}
}
}
}
func actionListener() {
actions := make(chan notify.Signal, 100)
go handleActions(mainCtx, actions)
err := notify.SignalNotify(mainCtx, actions)
if err != nil && errors.Is(err, context.Canceled) {
log.Errorf("notify: signal listener failed: %s", err)
}
}
// Show shows the notification.
func (n *Notification) Show() {
sysN := notify.NewNotification("Portmaster", n.Message)
// see https://developer.gnome.org/notification-spec/
// The optional name of the application sending the notification.
// Can be blank.
sysN.AppName = "Portmaster"
// The optional notification ID that this notification replaces.
sysN.ReplacesID = uint32(n.systemID)
// The optional program icon of the calling application.
// sysN.AppIcon string
// The summary text briefly describing the notification.
// Summary string (arg 1)
// The optional detailed body text.
// Body string (arg 2)
// The actions send a request message back to the notification client
// when invoked.
// sysN.Actions []string
if capabilities.Actions {
sysN.Actions = make([]string, 0, len(n.AvailableActions)*2)
for _, action := range n.AvailableActions {
if IsSupportedAction(*action) {
sysN.Actions = append(sysN.Actions, action.ID)
sysN.Actions = append(sysN.Actions, action.Text)
}
}
}
// Set Portmaster icon.
iconLocation, err := ensureAppIcon()
if err != nil {
log.Warningf("notify: failed to write icon: %s", err)
}
sysN.AppIcon = iconLocation
// TODO: Use hints to display icon of affected app.
// Hints are a way to provide extra data to a notification server.
// sysN.Hints = make(map[string]interface{})
// The timeout time in milliseconds since the display of the
// notification at which the notification should automatically close.
// sysN.Timeout int32
newID, err := sysN.Show()
if err != nil {
log.Warningf("notify: failed to show notification %s", n.EventID)
return
}
notifsByID.Store(NotificationID(newID), n)
n.Lock()
defer n.Unlock()
n.systemID = NotificationID(newID)
}
// Cancel cancels the notification.
func (n *Notification) Cancel() {
n.Lock()
defer n.Unlock()
// TODO: could a ID of 0 be valid?
if n.systemID != 0 {
err := notify.CloseNotification(uint32(n.systemID))
if err != nil {
log.Warningf("notify: failed to close notification %s/%d", n.EventID, n.systemID)
}
notifsByID.Delete(n.systemID)
}
}

View file

@ -0,0 +1,184 @@
package main
import (
"fmt"
"sync"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/cmds/notifier/wintoast"
"github.com/safing/portmaster/service/updates/helper"
)
type NotificationID int64
const (
appName = "Portmaster"
appUserModelID = "io.safing.portmaster.2"
originalShortcutPath = "C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Portmaster\\Portmaster.lnk"
)
const (
SoundDefault = 0
SoundSilent = 1
SoundLoop = 2
)
const (
SoundPathDefault = 0
// see notification_glue.h if you need more types
)
var (
initOnce sync.Once
lib *wintoast.WinToast
notificationsByIDs sync.Map
)
func getLib() *wintoast.WinToast {
initOnce.Do(func() {
dllPath, err := getDllPath()
if err != nil {
log.Errorf("notify: failed to get dll path: %s", err)
return
}
// Load dll and all the functions
newLib, err := wintoast.New(dllPath)
if err != nil {
log.Errorf("notify: failed to load library: %s", err)
return
}
// Initialize. This will create or update application shortcut. C:\Users\<user>\AppData\Roaming\Microsoft\Windows\Start Menu\Programs
// and it will be of the originalShortcutPath with no CLSID and different AUMI
err = newLib.Initialize(appName, appUserModelID, originalShortcutPath)
if err != nil {
log.Errorf("notify: failed to load library: %s", err)
return
}
// library was initialized successfully
lib = newLib
// Set callbacks
err = lib.SetCallbacks(notificationActivatedCallback, notificationDismissedCallback, notificationDismissedCallback)
if err != nil {
log.Warningf("notify: failed to set callbacks: %s", err)
return
}
})
return lib
}
// Show shows the notification.
func (n *Notification) Show() {
// Lock notification
n.Lock()
defer n.Unlock()
// Create new notification object
builder, err := getLib().NewNotification(n.Title, n.Message)
if err != nil {
log.Errorf("notify: failed to create notification: %s", err)
return
}
// Make sure memory is freed when done
defer builder.Delete()
// if needed set notification icon
// _ = builder.SetImage(iconLocation)
// Leaving the default value for the sound
// _ = builder.SetSound(SoundDefault, SoundPathDefault)
// Set all the required actions.
for _, action := range n.AvailableActions {
err = builder.AddButton(action.Text)
if err != nil {
log.Warningf("notify: failed to add button: %s", err)
}
}
// Show notification.
id, err := builder.Show()
if err != nil {
log.Errorf("notify: failed to show notification: %s", err)
return
}
n.systemID = NotificationID(id)
// Link system id to the notification object
notificationsByIDs.Store(NotificationID(id), n)
log.Debugf("notify: showing notification %q: %d", n.Title, n.systemID)
}
// Cancel cancels the notification.
func (n *Notification) Cancel() {
// Lock notification
n.Lock()
defer n.Unlock()
// No need to check for errors. If it fails it is probably already dismissed
_ = getLib().HideNotification(int64(n.systemID))
notificationsByIDs.Delete(n.systemID)
log.Debugf("notify: notification canceled %q: %d", n.Title, n.systemID)
}
func notificationActivatedCallback(id int64, actionIndex int32) {
if actionIndex == -1 {
// The user clicked on the notification (not a button), open the portmaster and delete
launchApp()
notificationsByIDs.Delete(NotificationID(id))
log.Debugf("notify: notification clicked %d", id)
return
}
// The user click one of the buttons
// Get notified object
n, ok := notificationsByIDs.LoadAndDelete(NotificationID(id))
if !ok {
return
}
notification := n.(*Notification)
notification.Lock()
defer notification.Unlock()
// Set selected action
actionID := notification.AvailableActions[actionIndex].ID
notification.SelectAction(actionID)
log.Debugf("notify: notification button cliecked %d button id: %d", id, actionIndex)
}
func notificationDismissedCallback(id int64, reason int32) {
// Failure or user dismissed the notification
if reason == 0 {
notificationsByIDs.Delete(NotificationID(id))
log.Debugf("notify: notification dissmissed %d", id)
}
}
func getDllPath() (string, error) {
if dataDir == "" {
return "", fmt.Errorf("dataDir is empty")
}
// Aks the registry for the dll path
identifier := helper.PlatformIdentifier("notifier/portmaster-wintoast.dll")
file, err := registry.GetFile(identifier)
if err != nil {
return "", err
}
return file.Path(), nil
}
func actionListener() {
// initialize the library
_ = getLib()
}

50
cmds/notifier/shutdown.go Normal file
View file

@ -0,0 +1,50 @@
package main
import (
"github.com/safing/portmaster/base/api/client"
"github.com/safing/portmaster/base/log"
)
func startShutdownEventListener() {
shutdownNotifOp := apiClient.Sub("query runtime:modules/core/event/shutdown", handleShutdownEvent)
shutdownNotifOp.EnableResuscitation()
restartNotifOp := apiClient.Sub("query runtime:modules/core/event/restart", handleRestartEvent)
restartNotifOp.EnableResuscitation()
}
func handleShutdownEvent(m *client.Message) {
switch m.Type {
case client.MsgOk, client.MsgUpdate, client.MsgNew:
shuttingDown.Set()
triggerTrayUpdate()
log.Warningf("shutdown: received shutdown event, shutting down now")
// wait for the API client connection to die
<-apiClient.Offline()
shuttingDown.UnSet()
cancelMainCtx()
case client.MsgWarning, client.MsgError:
log.Errorf("shutdown: event subscription error: %s", string(m.RawValue))
}
}
func handleRestartEvent(m *client.Message) {
switch m.Type {
case client.MsgOk, client.MsgUpdate, client.MsgNew:
restarting.Set()
triggerTrayUpdate()
log.Warningf("restart: received restart event")
// wait for the API client connection to die
<-apiClient.Offline()
restarting.UnSet()
triggerTrayUpdate()
case client.MsgWarning, client.MsgError:
log.Errorf("shutdown: event subscription error: %s", string(m.RawValue))
}
}

View file

@ -0,0 +1,15 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 498226a..446ba5e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.4)
project(snoretoast VERSION 0.6.0)
# Always change the guid when the version is changed SNORETOAST_CALLBACK_GUID
-set(SNORETOAST_CALLBACK_GUID eb1fdd5b-8f70-4b5a-b230-998a2dc19303)
+#We keep it fixed!
+set(SNORETOAST_CALLBACK_GUID 7F00FB48-65D5-4BA8-A35B-F194DA7E1A51)
+
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake/)

104
cmds/notifier/spn.go Normal file
View file

@ -0,0 +1,104 @@
package main
import (
"sync"
"time"
"github.com/tevino/abool"
"github.com/safing/portmaster/base/api/client"
"github.com/safing/portmaster/base/log"
"github.com/safing/structures/dsd"
)
const (
spnModuleKey = "config:spn/enable"
spnStatusKey = "runtime:spn/status"
)
var (
spnEnabled = abool.New()
spnStatusCache *SPNStatus
spnStatusCacheLock sync.Mutex
)
// SPNStatus holds SPN status information.
type SPNStatus struct {
Status string
HomeHubID string
HomeHubName string
ConnectedIP string
ConnectedTransport string
ConnectedSince *time.Time
}
// GetSPNStatus returns the SPN status.
func GetSPNStatus() *SPNStatus {
spnStatusCacheLock.Lock()
defer spnStatusCacheLock.Unlock()
return spnStatusCache
}
func updateSPNStatus(s *SPNStatus) {
spnStatusCacheLock.Lock()
defer spnStatusCacheLock.Unlock()
spnStatusCache = s
}
func spnStatusClient() {
moduleQueryOp := apiClient.Qsub(query+spnModuleKey, handleSPNModuleUpdate)
moduleQueryOp.EnableResuscitation()
statusQueryOp := apiClient.Qsub(query+spnStatusKey, handleSPNStatusUpdate)
statusQueryOp.EnableResuscitation()
}
func handleSPNModuleUpdate(m *client.Message) {
switch m.Type {
case client.MsgOk, client.MsgUpdate, client.MsgNew:
var cfg struct {
Value bool `json:"Value"`
}
_, err := dsd.Load(m.RawValue, &cfg)
if err != nil {
log.Warningf("config: failed to parse config: %s", err)
return
}
log.Infof("config: received update to SPN module: enabled=%v", cfg.Value)
spnEnabled.SetTo(cfg.Value)
triggerTrayUpdate()
default:
}
}
func handleSPNStatusUpdate(m *client.Message) {
switch m.Type {
case client.MsgOk, client.MsgUpdate, client.MsgNew:
newStatus := &SPNStatus{}
_, err := dsd.Load(m.RawValue, newStatus)
if err != nil {
log.Warningf("config: failed to parse config: %s", err)
return
}
log.Infof("config: received update to SPN status: %+v", newStatus)
updateSPNStatus(newStatus)
triggerTrayUpdate()
default:
}
}
func ToggleSPN() {
var cfg struct {
Value bool `json:"Value"`
}
cfg.Value = !spnEnabled.IsSet()
apiClient.Update(spnModuleKey, &cfg, nil)
}

121
cmds/notifier/subsystems.go Normal file
View file

@ -0,0 +1,121 @@
package main
import (
"sync"
"github.com/safing/portmaster/base/api/client"
"github.com/safing/portmaster/base/log"
"github.com/safing/structures/dsd"
)
const (
subsystemsKeySpace = "runtime:subsystems/"
// Module Failure Status Values
// FailureNone = 0 // unused
// FailureHint = 1 // unused.
FailureWarning = 2
FailureError = 3
)
var (
subsystems = make(map[string]*Subsystem)
subsystemsLock sync.Mutex
)
// Subsystem describes a subset of modules that represent a part of a
// service or program to the user. Subsystems can be (de-)activated causing
// all related modules to be brought down or up.
type Subsystem struct { //nolint:maligned // not worth the effort
// ID is a unique identifier for the subsystem.
ID string
// Name holds a human readable name of the subsystem.
Name string
// Description may holds an optional description of
// the subsystem's purpose.
Description string
// Modules contains all modules that are related to the subsystem.
// Note that this slice also contains a reference to the subsystem
// module itself.
Modules []*ModuleStatus
// FailureStatus is the worst failure status that is currently
// set in one of the subsystem's dependencies.
FailureStatus uint8
}
// ModuleStatus describes the status of a module.
type ModuleStatus struct {
Name string
Enabled bool
Status uint8
FailureStatus uint8
FailureID string
FailureMsg string
}
// GetFailure returns the worst of all subsystem failures.
func GetFailure() (failureStatus uint8, failureMsg string) {
subsystemsLock.Lock()
defer subsystemsLock.Unlock()
for _, subsystem := range subsystems {
for _, module := range subsystem.Modules {
if failureStatus < module.FailureStatus {
failureStatus = module.FailureStatus
failureMsg = module.FailureMsg
}
}
}
return
}
func updateSubsystem(s *Subsystem) {
subsystemsLock.Lock()
defer subsystemsLock.Unlock()
subsystems[s.ID] = s
}
func clearSubsystems() {
subsystemsLock.Lock()
defer subsystemsLock.Unlock()
for key := range subsystems {
delete(subsystems, key)
}
}
func subsystemsClient() {
subsystemsOp := apiClient.Qsub("query "+subsystemsKeySpace, handleSubsystem)
subsystemsOp.EnableResuscitation()
}
func handleSubsystem(m *client.Message) {
switch m.Type {
case client.MsgError:
case client.MsgDone:
case client.MsgSuccess:
case client.MsgOk, client.MsgUpdate, client.MsgNew:
newSubsystem := &Subsystem{}
_, err := dsd.Load(m.RawValue, newSubsystem)
if err != nil {
log.Warningf("subsystems: failed to parse new subsystem: %s", err)
return
}
updateSubsystem(newSubsystem)
triggerTrayUpdate()
case client.MsgDelete:
case client.MsgWarning:
case client.MsgOffline:
clearSubsystems()
}
}

217
cmds/notifier/tray.go Normal file
View file

@ -0,0 +1,217 @@
package main
import (
"flag"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"fyne.io/systray"
icons "github.com/safing/portmaster/assets"
"github.com/safing/portmaster/base/log"
)
const (
shortenStatusMsgTo = 40
)
var (
trayLock sync.Mutex
scaleColoredIconsTo int
activeIconID int = -1
activeStatusMsg = ""
activeSPNStatus = ""
activeSPNSwitch = ""
menuItemStatusMsg *systray.MenuItem
menuItemSPNStatus *systray.MenuItem
menuItemSPNSwitch *systray.MenuItem
)
func init() {
flag.IntVar(&scaleColoredIconsTo, "scale-icons", 32, "scale colored icons to given size in pixels")
// lock until ready
trayLock.Lock()
}
func tray() {
if scaleColoredIconsTo > 0 {
icons.ScaleColoredIconsTo(scaleColoredIconsTo)
}
systray.Run(onReady, onExit)
}
func exitTray() {
systray.Quit()
}
func onReady() {
// unlock when ready
defer trayLock.Unlock()
// icon
systray.SetIcon(icons.ColoredIcons[icons.RedID])
if runtime.GOOS == "windows" {
// systray.SetTitle("Portmaster Notifier") // Don't set title, as it may be displayed in full in the menu/tray bar. (Ubuntu)
systray.SetTooltip("Portmaster Notifier")
}
// menu: open app
if dataDir != "" {
menuItemOpenApp := systray.AddMenuItem("Open App", "")
go clickListener(menuItemOpenApp, launchApp)
systray.AddSeparator()
}
// menu: status
menuItemStatusMsg = systray.AddMenuItem("Loading...", "")
menuItemStatusMsg.Disable()
systray.AddSeparator()
// menu: SPN
menuItemSPNStatus = systray.AddMenuItem("Loading...", "")
menuItemSPNStatus.Disable()
menuItemSPNSwitch = systray.AddMenuItem("Loading...", "")
go clickListener(menuItemSPNSwitch, func() {
ToggleSPN()
})
systray.AddSeparator()
// menu: quit
systray.AddSeparator()
closeTray := systray.AddMenuItem("Close Tray Notifier", "")
go clickListener(closeTray, func() {
cancelMainCtx()
})
shutdownPortmaster := systray.AddMenuItem("Shut Down Portmaster", "")
go clickListener(shutdownPortmaster, func() {
_ = TriggerShutdown()
time.Sleep(1 * time.Second)
cancelMainCtx()
})
}
func onExit() {
}
func triggerTrayUpdate() {
// TODO: Deduplicate triggers.
go updateTray()
}
// updateTray update the state of the tray depending on the currently available information.
func updateTray() {
// Get current information.
spnStatus := GetSPNStatus()
failureID, failureMsg := GetFailure()
trayLock.Lock()
defer trayLock.Unlock()
// Select icon and status message to show.
newIconID := icons.GreenID
newStatusMsg := "Secure"
switch {
case shuttingDown.IsSet():
newIconID = icons.RedID
newStatusMsg = "Shutting Down Portmaster"
case restarting.IsSet():
newIconID = icons.YellowID
newStatusMsg = "Restarting Portmaster"
case !connected.IsSet():
newIconID = icons.RedID
newStatusMsg = "Waiting for Portmaster Core Service"
case failureID == FailureError:
newIconID = icons.RedID
newStatusMsg = failureMsg
case failureID == FailureWarning:
newIconID = icons.YellowID
newStatusMsg = failureMsg
case spnEnabled.IsSet():
newIconID = icons.BlueID
}
// Set icon if changed.
if newIconID != activeIconID {
activeIconID = newIconID
systray.SetIcon(icons.ColoredIcons[activeIconID])
}
// Set message if changed.
if newStatusMsg != activeStatusMsg {
activeStatusMsg = newStatusMsg
// Shorten message if too long.
shortenedMsg := activeStatusMsg
if len(shortenedMsg) > shortenStatusMsgTo && strings.Contains(shortenedMsg, ". ") {
shortenedMsg = strings.SplitN(shortenedMsg, ". ", 2)[0]
}
if len(shortenedMsg) > shortenStatusMsgTo {
shortenedMsg = shortenedMsg[:shortenStatusMsgTo] + "..."
}
menuItemStatusMsg.SetTitle("Status: " + shortenedMsg)
}
// Set SPN status if changed.
if spnStatus != nil && activeSPNStatus != spnStatus.Status {
activeSPNStatus = spnStatus.Status
menuItemSPNStatus.SetTitle("SPN: " + strings.Title(activeSPNStatus)) // nolint:staticcheck
}
// Set SPN switch if changed.
newSPNSwitch := "Enable SPN"
if spnEnabled.IsSet() {
newSPNSwitch = "Disable SPN"
}
if activeSPNSwitch != newSPNSwitch {
activeSPNSwitch = newSPNSwitch
menuItemSPNSwitch.SetTitle(activeSPNSwitch)
}
}
func clickListener(item *systray.MenuItem, fn func()) {
for range item.ClickedCh {
fn()
}
}
func launchApp() {
// build path to app
pmStartPath := filepath.Join(dataDir, "portmaster-start")
if runtime.GOOS == "windows" {
pmStartPath += ".exe"
}
// start app
cmd := exec.Command(pmStartPath, "app", "--data", dataDir)
err := cmd.Start()
if err != nil {
log.Warningf("failed to start app: %s", err)
return
}
// Use cmd.Wait() instead of cmd.Process.Release() to properly release its resources.
// See https://github.com/golang/go/issues/36534
go func() {
err := cmd.Wait()
if err != nil {
log.Warningf("failed to wait/release app process: %s", err)
}
}()
}

View file

@ -0,0 +1,90 @@
//go:build windows
package wintoast
import (
"unsafe"
"golang.org/x/sys/windows"
)
type NotificationBuilder struct {
templatePointer uintptr
lib *WinToast
}
func newNotification(lib *WinToast, title string, message string) (*NotificationBuilder, error) {
lib.Lock()
defer lib.Unlock()
titleUTF, _ := windows.UTF16PtrFromString(title)
messageUTF, _ := windows.UTF16PtrFromString(message)
titleP := unsafe.Pointer(titleUTF)
messageP := unsafe.Pointer(messageUTF)
ptr, _, err := lib.createNotification.Call(uintptr(titleP), uintptr(messageP))
if ptr == 0 {
return nil, err
}
return &NotificationBuilder{ptr, lib}, nil
}
func (n *NotificationBuilder) Delete() {
if n == nil {
return
}
n.lib.Lock()
defer n.lib.Unlock()
_, _, _ = n.lib.deleteNotification.Call(n.templatePointer)
}
func (n *NotificationBuilder) AddButton(text string) error {
n.lib.Lock()
defer n.lib.Unlock()
textUTF, _ := windows.UTF16PtrFromString(text)
textP := unsafe.Pointer(textUTF)
rc, _, err := n.lib.addButton.Call(n.templatePointer, uintptr(textP))
if rc != 1 {
return err
}
return nil
}
func (n *NotificationBuilder) SetImage(iconPath string) error {
n.lib.Lock()
defer n.lib.Unlock()
pathUTF, _ := windows.UTF16PtrFromString(iconPath)
pathP := unsafe.Pointer(pathUTF)
rc, _, err := n.lib.setImage.Call(n.templatePointer, uintptr(pathP))
if rc != 1 {
return err
}
return nil
}
func (n *NotificationBuilder) SetSound(option int, path int) error {
n.lib.Lock()
defer n.lib.Unlock()
rc, _, err := n.lib.setSound.Call(n.templatePointer, uintptr(option), uintptr(path))
if rc != 1 {
return err
}
return nil
}
func (n *NotificationBuilder) Show() (int64, error) {
n.lib.Lock()
defer n.lib.Unlock()
id, _, err := n.lib.showNotification.Call(n.templatePointer)
if int64(id) == -1 {
return -1, err
}
return int64(id), nil
}

View file

@ -0,0 +1,217 @@
//go:build windows
package wintoast
import (
"fmt"
"sync"
"unsafe"
"github.com/tevino/abool"
"golang.org/x/sys/windows"
)
// WinNotify holds the DLL handle.
type WinToast struct {
sync.RWMutex
dll *windows.DLL
initialized *abool.AtomicBool
initialize *windows.Proc
isInitialized *windows.Proc
createNotification *windows.Proc
deleteNotification *windows.Proc
addButton *windows.Proc
setImage *windows.Proc
setSound *windows.Proc
showNotification *windows.Proc
hideNotification *windows.Proc
setActivatedCallback *windows.Proc
setDismissedCallback *windows.Proc
setFailedCallback *windows.Proc
}
func New(dllPath string) (*WinToast, error) {
if dllPath == "" {
return nil, fmt.Errorf("winnotifiy: path to dll not specified")
}
libraryObject := &WinToast{}
libraryObject.initialized = abool.New()
// load dll
var err error
libraryObject.dll, err = windows.LoadDLL(dllPath)
if err != nil {
return nil, fmt.Errorf("winnotifiy: failed to load notifier dll %w", err)
}
// load functions
libraryObject.initialize, err = libraryObject.dll.FindProc("PortmasterToastInitialize")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastInitialize not found %w", err)
}
libraryObject.isInitialized, err = libraryObject.dll.FindProc("PortmasterToastIsInitialized")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastIsInitialized not found %w", err)
}
libraryObject.createNotification, err = libraryObject.dll.FindProc("PortmasterToastCreateNotification")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastCreateNotification not found %w", err)
}
libraryObject.deleteNotification, err = libraryObject.dll.FindProc("PortmasterToastDeleteNotification")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastDeleteNotification not found %w", err)
}
libraryObject.addButton, err = libraryObject.dll.FindProc("PortmasterToastAddButton")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastAddButton not found %w", err)
}
libraryObject.setImage, err = libraryObject.dll.FindProc("PortmasterToastSetImage")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastSetImage not found %w", err)
}
libraryObject.setSound, err = libraryObject.dll.FindProc("PortmasterToastSetSound")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastSetSound not found %w", err)
}
libraryObject.showNotification, err = libraryObject.dll.FindProc("PortmasterToastShow")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastShow not found %w", err)
}
libraryObject.setActivatedCallback, err = libraryObject.dll.FindProc("PortmasterToastActivatedCallback")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterActivatedCallback not found %w", err)
}
libraryObject.setDismissedCallback, err = libraryObject.dll.FindProc("PortmasterToastDismissedCallback")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastDismissedCallback not found %w", err)
}
libraryObject.setFailedCallback, err = libraryObject.dll.FindProc("PortmasterToastFailedCallback")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastFailedCallback not found %w", err)
}
libraryObject.hideNotification, err = libraryObject.dll.FindProc("PortmasterToastHide")
if err != nil {
return nil, fmt.Errorf("winnotifiy: PortmasterToastHide not found %w", err)
}
return libraryObject, nil
}
func (lib *WinToast) Initialize(appName, aumi, originalShortcutPath string) error {
if lib == nil {
return fmt.Errorf("wintoast: lib object was nil")
}
lib.Lock()
defer lib.Unlock()
// Initialize all necessary string for the notification meta data
appNameUTF, _ := windows.UTF16PtrFromString(appName)
aumiUTF, _ := windows.UTF16PtrFromString(aumi)
linkUTF, _ := windows.UTF16PtrFromString(originalShortcutPath)
// They are needed as unsafe pointers
appNameP := unsafe.Pointer(appNameUTF)
aumiP := unsafe.Pointer(aumiUTF)
linkP := unsafe.Pointer(linkUTF)
// Initialize notifications
rc, _, err := lib.initialize.Call(uintptr(appNameP), uintptr(aumiP), uintptr(linkP))
if rc != 0 {
return fmt.Errorf("wintoast: failed to initialize library rc = %d, %w", rc, err)
}
// Check if if the initialization was successfully
rc, _, _ = lib.isInitialized.Call()
if rc == 1 {
lib.initialized.Set()
} else {
return fmt.Errorf("wintoast: initialized flag was not set: rc = %d", rc)
}
return nil
}
func (lib *WinToast) SetCallbacks(activated func(id int64, actionIndex int32), dismissed func(id int64, reason int32), failed func(id int64, reason int32)) error {
if lib == nil {
return fmt.Errorf("wintoast: lib object was nil")
}
if lib.initialized.IsNotSet() {
return fmt.Errorf("winnotifiy: library not initialized")
}
// Initialize notification activated callback
callback := windows.NewCallback(func(id int64, actionIndex int32) uint64 {
activated(id, actionIndex)
return 0
})
rc, _, err := lib.setActivatedCallback.Call(callback)
if rc != 1 {
return fmt.Errorf("winnotifiy: failed to initialize activated callback %w", err)
}
// Initialize notification dismissed callback
callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 {
dismissed(id, actionIndex)
return 0
})
rc, _, err = lib.setDismissedCallback.Call(callback)
if rc != 1 {
return fmt.Errorf("winnotifiy: failed to initialize dismissed callback %w", err)
}
// Initialize notification failed callback
callback = windows.NewCallback(func(id int64, actionIndex int32) uint64 {
failed(id, actionIndex)
return 0
})
rc, _, err = lib.setFailedCallback.Call(callback)
if rc != 1 {
return fmt.Errorf("winnotifiy: failed to initialize failed callback %s", err)
}
return nil
}
// NewNotification starts a creation of new notification. NotificationBuilder.Delete should allays be called when done using the object or there will be memory leeks
func (lib *WinToast) NewNotification(title string, content string) (*NotificationBuilder, error) {
if lib == nil {
return nil, fmt.Errorf("wintoast: lib object was nil")
}
return newNotification(lib, title, content)
}
// HideNotification hides notification
func (lib *WinToast) HideNotification(id int64) error {
if lib == nil {
return fmt.Errorf("wintoast: lib object was nil")
}
lib.Lock()
defer lib.Unlock()
rc, _, _ := lib.hideNotification.Call(uintptr(id))
if rc != 1 {
return fmt.Errorf("wintoast: failed to hide notification %d", id)
}
return nil
}

View file

@ -19,6 +19,7 @@ import (
"github.com/safing/portmaster/base/metrics"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/service/updates"
"github.com/safing/portmaster/service/updates/helper"
"github.com/safing/portmaster/spn"
"github.com/safing/portmaster/spn/captain"
"github.com/safing/portmaster/spn/conf"
@ -37,6 +38,7 @@ func main() {
// Configure user agent and updates.
updates.UserAgent = fmt.Sprintf("SPN Observation Hub (%s %s)", runtime.GOOS, runtime.GOARCH)
helper.IntelOnly()
// Configure SPN mode.
conf.EnableClient(true)
@ -46,8 +48,9 @@ func main() {
sluice.EnableListener = false
api.EnableServer = false
// Start logger with default log level.
_ = log.Start(log.WarningLevel)
// Set default log level.
log.SetLogLevel(log.WarningLevel)
_ = log.Start()
// Create instance.
var execCmdLine bool
@ -77,8 +80,6 @@ func main() {
}
instance.AddModule(observer)
// FIXME: Use service?
// Execute command line operation, if requested or available.
switch {
case !execCmdLine:
@ -127,7 +128,7 @@ func main() {
slog.Warn("program was interrupted, stopping")
}
case <-instance.ShuttingDown():
case <-instance.Stopped():
log.Shutdown()
os.Exit(instance.ExitCode())
}

View file

@ -1,59 +1,43 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"runtime"
"github.com/spf13/cobra"
"runtime/pprof"
"syscall"
"time"
"github.com/safing/portmaster/base/info"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/metrics"
"github.com/safing/portmaster/service"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/service/updates"
"github.com/safing/portmaster/spn/conf"
)
var (
rootCmd = &cobra.Command{
Use: "portmaster-core",
PersistentPreRun: initializeGlobals,
Run: cmdRun,
}
printStackOnExit bool
enableInputSignals bool
binDir string
dataDir string
logToStdout bool
logDir string
logLevel string
svcCfg *service.ServiceConfig
sigUSR1 = syscall.Signal(0xa) // dummy for windows
)
func init() {
// Add Go's default flag set.
// TODO: Move flags throughout Portmaster to here and add their values to the service config.
rootCmd.Flags().AddGoFlagSet(flag.CommandLine)
// Add persisent flags for all commands.
rootCmd.PersistentFlags().StringVar(&binDir, "bin-dir", "", "set directory for executable binaries (rw/ro)")
rootCmd.PersistentFlags().StringVar(&dataDir, "data-dir", "", "set directory for variable data (rw)")
// Add flags for service only.
rootCmd.Flags().BoolVar(&logToStdout, "log-stdout", false, "log to stdout instead of file")
rootCmd.Flags().StringVar(&logDir, "log-dir", "", "set directory for logs")
rootCmd.Flags().StringVar(&logLevel, "log", "", "set log level to [trace|debug|info|warning|error|critical]")
flag.BoolVar(&printStackOnExit, "print-stack-on-exit", false, "prints the stack before of shutting down")
flag.BoolVar(&enableInputSignals, "input-signals", false, "emulate signals using stdin")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
flag.Parse()
func initializeGlobals(cmd *cobra.Command, args []string) {
// set information
info.Set("Portmaster", "", "GPLv3")
@ -63,18 +47,150 @@ func initializeGlobals(cmd *cobra.Command, args []string) {
// Configure user agent.
updates.UserAgent = fmt.Sprintf("Portmaster Core (%s %s)", runtime.GOOS, runtime.GOARCH)
// Create service config.
svcCfg = &service.ServiceConfig{
BinDir: binDir,
DataDir: dataDir,
// enable SPN client mode
conf.EnableClient(true)
conf.EnableIntegration(true)
LogToStdout: logToStdout,
LogDir: logDir,
LogLevel: logLevel,
// Create instance.
var execCmdLine bool
instance, err := service.New(&service.ServiceConfig{})
switch {
case err == nil:
// Continue
case errors.Is(err, mgr.ErrExecuteCmdLineOp):
execCmdLine = true
default:
fmt.Printf("error creating an instance: %s\n", err)
os.Exit(2)
}
BinariesIndexURLs: service.DefaultStableBinaryIndexURLs,
IntelIndexURLs: service.DefaultIntelIndexURLs,
VerifyBinaryUpdates: service.BinarySigningTrustStore,
VerifyIntelUpdates: service.BinarySigningTrustStore,
// Execute command line operation, if requested or available.
switch {
case !execCmdLine:
// Run service.
case instance.CommandLineOperation == nil:
fmt.Println("command line operation execution requested, but not set")
os.Exit(3)
default:
// Run the function and exit.
err = instance.CommandLineOperation()
if err != nil {
fmt.Fprintf(os.Stderr, "command line operation failed: %s\n", err)
os.Exit(3)
}
os.Exit(0)
}
// Set default log level.
log.SetLogLevel(log.WarningLevel)
_ = log.Start()
// Start
go func() {
err = instance.Start()
if err != nil {
fmt.Printf("instance start failed: %s\n", err)
// Print stack on start failure, if enabled.
if printStackOnExit {
printStackTo(os.Stdout, "PRINTING STACK ON START FAILURE")
}
os.Exit(1)
}
}()
// Wait for signal.
signalCh := make(chan os.Signal, 1)
if enableInputSignals {
go inputSignals(signalCh)
}
signal.Notify(
signalCh,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
sigUSR1,
)
select {
case sig := <-signalCh:
// Only print and continue to wait if SIGUSR1
if sig == sigUSR1 {
printStackTo(os.Stderr, "PRINTING STACK ON REQUEST")
} else {
fmt.Println(" <INTERRUPT>") // CLI output.
slog.Warn("program was interrupted, stopping")
}
case <-instance.Stopped():
log.Shutdown()
os.Exit(instance.ExitCode())
}
// Catch signals during shutdown.
// Rapid unplanned disassembly after 5 interrupts.
go func() {
forceCnt := 5
for {
<-signalCh
forceCnt--
if forceCnt > 0 {
fmt.Printf(" <INTERRUPT> again, but already shutting down - %d more to force\n", forceCnt)
} else {
printStackTo(os.Stderr, "PRINTING STACK ON FORCED EXIT")
os.Exit(1)
}
}
}()
// Rapid unplanned disassembly after 3 minutes.
go func() {
time.Sleep(3 * time.Minute)
printStackTo(os.Stderr, "PRINTING STACK - TAKING TOO LONG FOR SHUTDOWN")
os.Exit(1)
}()
// Stop instance.
if err := instance.Stop(); err != nil {
slog.Error("failed to stop", "err", err)
}
log.Shutdown()
// Print stack on shutdown, if enabled.
if printStackOnExit {
printStackTo(os.Stdout, "PRINTING STACK ON EXIT")
}
os.Exit(instance.ExitCode())
}
func printStackTo(writer io.Writer, msg string) {
_, err := fmt.Fprintf(writer, "===== %s =====\n", msg)
if err == nil {
err = pprof.Lookup("goroutine").WriteTo(writer, 1)
}
if err != nil {
slog.Error("failed to write stack trace", "err", err)
}
}
func inputSignals(signalCh chan os.Signal) {
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
switch scanner.Text() {
case "SIGHUP":
signalCh <- syscall.SIGHUP
case "SIGINT":
signalCh <- syscall.SIGINT
case "SIGQUIT":
signalCh <- syscall.SIGQUIT
case "SIGTERM":
signalCh <- syscall.SIGTERM
case "SIGUSR1":
signalCh <- sigUSR1
}
}
}

View file

@ -1,90 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"github.com/safing/portmaster/service/firewall/interception"
)
var (
recoverCmd = &cobra.Command{
Use: "recover-iptables",
Short: "Force an update of all components.",
RunE: update,
}
recoverIPTables bool
)
func init() {
rootCmd.AddCommand(recoverCmd)
flag.BoolVar(&recoverIPTables, "recover-iptables", false, "recovers ip table rules (backward compatibility; use command instead)")
}
func recover(cmd *cobra.Command, args []string) error {
// interception.DeactiveNfqueueFirewall uses coreos/go-iptables
// which shells out to the /sbin/iptables binary. As a result,
// we don't get the errno of the actual error and need to parse the
// output instead. Make sure it's always english by setting LC_ALL=C
currentLocale := os.Getenv("LC_ALL")
_ = os.Setenv("LC_ALL", "C")
defer func() {
_ = os.Setenv("LC_ALL", currentLocale)
}()
err := interception.DeactivateNfqueueFirewall()
if err == nil {
return nil
}
// we don't want to show ErrNotExists to the user
// as that only means portmaster did the cleanup itself.
var mr *multierror.Error
if !errors.As(err, &mr) {
return err
}
var filteredErrors *multierror.Error
for _, err := range mr.Errors {
// if we have a permission denied error, all errors will be the same
if strings.Contains(err.Error(), "Permission denied") {
return fmt.Errorf("failed to cleanup iptables: %w", os.ErrPermission)
}
if !strings.Contains(err.Error(), "No such file or directory") {
filteredErrors = multierror.Append(filteredErrors, err)
}
}
if filteredErrors != nil {
filteredErrors.ErrorFormat = formatNfqErrors
return filteredErrors.ErrorOrNil()
}
return nil
}
func formatNfqErrors(es []error) string {
if len(es) == 1 {
return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", es[0])
}
points := make([]string, len(es))
for i, err := range es {
// only display the very first line of each error
first := strings.Split(err.Error(), "\n")[0]
points[i] = fmt.Sprintf("* %s", first)
}
return fmt.Sprintf(
"%d errors occurred:\n\t%s\n\n",
len(es), strings.Join(points, "\n\t"))
}

View file

@ -1,140 +0,0 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"runtime/pprof"
"time"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/service"
"github.com/safing/portmaster/service/mgr"
"github.com/safing/portmaster/spn/conf"
)
var printStackOnExit bool
func init() {
flag.BoolVar(&printStackOnExit, "print-stack-on-exit", false, "prints the stack before of shutting down")
}
type SystemService interface {
Run()
IsService() bool
RestartService() error
}
func cmdRun(cmd *cobra.Command, args []string) {
// Run platform specific setup or switches.
runPlatformSpecifics(cmd, args)
// SETUP
// Enable SPN client mode.
// TODO: Move this to service config.
conf.EnableClient(true)
conf.EnableIntegration(true)
// Create instance.
// Instance modules might request a cmdline execution of a function.
var execCmdLine bool
instance, err := service.New(svcCfg)
switch {
case err == nil:
// Continue
case errors.Is(err, mgr.ErrExecuteCmdLineOp):
execCmdLine = true
default:
fmt.Printf("error creating an instance: %s\n", err)
os.Exit(2)
}
// Execute module command line operation, if requested or available.
switch {
case !execCmdLine:
// Run service.
case instance.CommandLineOperation == nil:
fmt.Println("command line operation execution requested, but not set")
os.Exit(3)
default:
// Run the function and exit.
fmt.Println("executing cmdline op")
err = instance.CommandLineOperation()
if err != nil {
fmt.Fprintf(os.Stderr, "command line operation failed: %s\n", err)
os.Exit(3)
}
os.Exit(0)
}
// START
// FIXME: fix color and duplicate level when logging with slog
// FIXME: check for tty for color enabling
// Start logging.
err = log.Start(svcCfg.LogLevel, svcCfg.LogToStdout, svcCfg.LogDir)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(4)
}
// Create system service.
service := NewSystemService(instance)
// Start instance via system service manager.
go func() {
service.Run()
}()
// SHUTDOWN
// Wait for shutdown to be started.
<-instance.ShuttingDown()
// Wait for shutdown to be finished.
select {
case <-instance.ShutdownComplete():
// Print stack on shutdown, if enabled.
if printStackOnExit {
printStackTo(log.GlobalWriter, "PRINTING STACK ON EXIT")
}
case <-time.After(3 * time.Minute):
printStackTo(log.GlobalWriter, "PRINTING STACK - TAKING TOO LONG FOR SHUTDOWN")
}
// Check if restart was triggered and send start service command if true.
if instance.ShouldRestart && service.IsService() {
if err := service.RestartService(); err != nil {
slog.Error("failed to restart service", "err", err)
}
}
// Stop logging.
log.Shutdown()
// Give a small amount of time for everything to settle:
// - All logs written.
// - Restart command started, if needed.
// - Windows service manager notified.
time.Sleep(100 * time.Millisecond)
// Exit
os.Exit(instance.ExitCode())
}
func printStackTo(writer io.Writer, msg string) {
_, err := fmt.Fprintf(writer, "===== %s =====\n", msg)
if err == nil {
err = pprof.Lookup("goroutine").WriteTo(writer, 1)
}
if err != nil {
slog.Error("failed to write stack trace", "err", err)
}
}

View file

@ -1,145 +0,0 @@
package main
import (
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"syscall"
processInfo "github.com/shirou/gopsutil/process"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/service"
)
type LinuxSystemService struct {
instance *service.Instance
}
func NewSystemService(instance *service.Instance) *LinuxSystemService {
return &LinuxSystemService{instance: instance}
}
func (s *LinuxSystemService) Run() {
// Start instance.
err := s.instance.Start()
if err != nil {
slog.Error("failed to start", "err", err)
// Print stack on start failure, if enabled.
if printStackOnExit {
printStackTo(log.GlobalWriter, "PRINTING STACK ON START FAILURE")
}
os.Exit(1)
}
// Subscribe to signals.
signalCh := make(chan os.Signal, 1)
signal.Notify(
signalCh,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
syscall.SIGUSR1,
)
// Wait for shutdown signal.
wait:
for {
select {
case <-s.instance.ShuttingDown():
break wait
case sig := <-signalCh:
// Only print and continue to wait if SIGUSR1
if sig == syscall.SIGUSR1 {
printStackTo(log.GlobalWriter, "PRINTING STACK ON REQUEST")
continue wait
} else {
// Trigger shutdown.
fmt.Printf(" <SIGNAL: %v>", sig) // CLI output.
slog.Warn("received stop signal", "signal", sig)
s.instance.Shutdown()
break wait
}
}
}
// Wait for shutdown to finish.
// Catch signals during shutdown.
// Force exit after 5 interrupts.
forceCnt := 5
for {
select {
case <-s.instance.ShutdownComplete():
return
case sig := <-signalCh:
if sig != syscall.SIGUSR1 {
forceCnt--
if forceCnt > 0 {
fmt.Printf(" <SIGNAL: %s> again, but already shutting down - %d more to force\n", sig, forceCnt)
} else {
printStackTo(log.GlobalWriter, "PRINTING STACK ON FORCED EXIT")
os.Exit(1)
}
}
}
}
}
func (s *LinuxSystemService) RestartService() error {
// Check if user defined custom command for restarting the service.
restartCommand, exists := os.LookupEnv("PORTMASTER_RESTART_COMMAND")
// Run the service restart
var cmd *exec.Cmd
if exists && restartCommand != "" {
slog.Debug("running custom restart command", "command", restartCommand)
cmd = exec.Command("sh", "-c", restartCommand)
} else {
cmd = exec.Command("systemctl", "restart", "portmaster")
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed run restart command: %w", err)
}
return nil
}
func (s *LinuxSystemService) IsService() bool {
// Get own process ID
pid := os.Getpid()
// Get parent process ID.
currentProcess, err := processInfo.NewProcess(int32(pid)) //nolint:gosec
if err != nil {
return false
}
ppid, err := currentProcess.Ppid()
if err != nil {
return false
}
// Check if the parent process ID is 1 == init system
return ppid == 1
}
func runPlatformSpecifics(cmd *cobra.Command, args []string) {
// If recover-iptables flag is set, run the recover-iptables command.
// This is for backwards compatibility
if recoverIPTables {
exitCode := 0
err := recover(cmd, args)
if err != nil {
fmt.Printf("failed: %s", err)
exitCode = 1
}
os.Exit(exitCode)
}
}

View file

@ -1,241 +0,0 @@
package main
// Based on the official Go examples from
// https://github.com/golang/sys/blob/master/windows/svc/example
// by The Go Authors.
// Original LICENSE (sha256sum: 2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067) can be found in vendor/pkg cache directory.
import (
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/service"
)
const serviceName = "PortmasterCore"
type WindowsSystemService struct {
instance *service.Instance
}
func NewSystemService(instance *service.Instance) *WindowsSystemService {
return &WindowsSystemService{instance: instance}
}
func (s *WindowsSystemService) Run() {
svcRun := svc.Run
// Check if we are running interactively.
isService, err := svc.IsWindowsService()
switch {
case err != nil:
slog.Warn("failed to determine if running interactively", "err", err)
slog.Warn("continuing without service integration (no real service)")
svcRun = debug.Run
case !isService:
slog.Warn("running interactively, switching to debug execution (no real service)")
svcRun = debug.Run
}
// Run service client.
err = svcRun(serviceName, s)
if err != nil {
slog.Error("service execution failed", "err", err)
os.Exit(1)
}
// Execution continues in s.Execute().
}
func (s *WindowsSystemService) Execute(args []string, changeRequests <-chan svc.ChangeRequest, changes chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
// Tell service manager we are starting.
changes <- svc.Status{State: svc.StartPending}
// Start instance.
err := s.instance.Start()
if err != nil {
fmt.Printf("failed to start: %s\n", err)
// Print stack on start failure, if enabled.
if printStackOnExit {
printStackTo(log.GlobalWriter, "PRINTING STACK ON START FAILURE")
}
// Notify service manager we stopped again.
changes <- svc.Status{State: svc.Stopped}
// Relay exit code to service manager.
return false, 1
}
// Tell service manager we are up and running!
changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
// Subscribe to signals.
// Docs: https://pkg.go.dev/os/signal?GOOS=windows
signalCh := make(chan os.Signal, 4)
signal.Notify(
signalCh,
// Windows ^C (Control-C) or ^BREAK (Control-Break).
// Completely prevents kill.
os.Interrupt,
// Windows CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT or CTRL_SHUTDOWN_EVENT.
// Does not prevent kill, but gives a little time to stop service.
syscall.SIGTERM,
)
// Wait for shutdown signal.
waitSignal:
for {
select {
case sig := <-signalCh:
// Trigger shutdown.
fmt.Printf(" <SIGNAL: %v>", sig) // CLI output.
slog.Warn("received stop signal", "signal", sig)
break waitSignal
case c := <-changeRequests:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
fmt.Printf(" <SERVICE CMD: %v>", serviceCmdName(c.Cmd)) // CLI output.
slog.Warn("received service shutdown command", "cmd", c.Cmd)
break waitSignal
default:
slog.Error("unexpected service control request", "cmd", serviceCmdName(c.Cmd))
}
case <-s.instance.ShuttingDown():
break waitSignal
}
}
// Wait for shutdown to finish.
changes <- svc.Status{State: svc.StopPending}
// Catch signals during shutdown.
// Force exit after 5 interrupts.
forceCnt := 5
waitShutdown:
for {
select {
case <-s.instance.ShutdownComplete():
break waitShutdown
case sig := <-signalCh:
forceCnt--
if forceCnt > 0 {
fmt.Printf(" <SIGNAL: %s> but already shutting down - %d more to force\n", sig, forceCnt)
} else {
printStackTo(log.GlobalWriter, "PRINTING STACK ON FORCED EXIT")
os.Exit(1)
}
case c := <-changeRequests:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
forceCnt--
if forceCnt > 0 {
fmt.Printf(" <SERVICE CMD: %v> but already shutting down - %d more to force\n", serviceCmdName(c.Cmd), forceCnt)
} else {
printStackTo(log.GlobalWriter, "PRINTING STACK ON FORCED EXIT")
os.Exit(1)
}
default:
slog.Error("unexpected service control request", "cmd", serviceCmdName(c.Cmd))
}
}
}
// Notify service manager.
changes <- svc.Status{State: svc.Stopped}
return false, 0
}
func (s *WindowsSystemService) IsService() bool {
isService, err := svc.IsWindowsService()
if err != nil {
return false
}
return isService
}
func (s *WindowsSystemService) RestartService() error {
// Script that wait for portmaster service status to change to stop
// and then sends a start command for the same service.
command := `
$serviceName = "PortmasterCore"
while ((Get-Service -Name $serviceName).Status -ne 'Stopped') {
Start-Sleep -Seconds 1
}
sc.exe start $serviceName`
// Create the command to execute the PowerShell script
cmd := exec.Command("powershell.exe", "-Command", command)
// Start the command. The script will continue even after the parent process exits.
err := cmd.Start()
if err != nil {
return err
}
return nil
}
func runPlatformSpecifics(cmd *cobra.Command, args []string)
func serviceCmdName(cmd svc.Cmd) string {
switch cmd {
case svc.Stop:
return "Stop"
case svc.Pause:
return "Pause"
case svc.Continue:
return "Continue"
case svc.Interrogate:
return "Interrogate"
case svc.Shutdown:
return "Shutdown"
case svc.ParamChange:
return "ParamChange"
case svc.NetBindAdd:
return "NetBindAdd"
case svc.NetBindRemove:
return "NetBindRemove"
case svc.NetBindEnable:
return "NetBindEnable"
case svc.NetBindDisable:
return "NetBindDisable"
case svc.DeviceEvent:
return "DeviceEvent"
case svc.HardwareProfileChange:
return "HardwareProfileChange"
case svc.PowerEvent:
return "PowerEvent"
case svc.SessionChange:
return "SessionChange"
case svc.PreShutdown:
return "PreShutdown"
default:
return "Unknown Command"
}
}

View file

@ -1,77 +0,0 @@
package main
import (
"fmt"
"log/slog"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/notifications"
"github.com/safing/portmaster/service"
"github.com/safing/portmaster/service/updates"
)
var updateCmd = &cobra.Command{
Use: "update",
Short: "Force an update of all components.",
RunE: update,
}
func init() {
rootCmd.AddCommand(updateCmd)
}
func update(cmd *cobra.Command, args []string) error {
// Finalize config.
err := svcCfg.Init()
if err != nil {
return fmt.Errorf("internal configuration error: %w", err)
}
// Force logging to stdout.
svcCfg.LogToStdout = true
// Start logging.
_ = log.Start(svcCfg.LogLevel, svcCfg.LogToStdout, svcCfg.LogDir)
defer log.Shutdown()
// Create updaters.
instance := &updateDummyInstance{}
binaryUpdateConfig, intelUpdateConfig, err := service.MakeUpdateConfigs(svcCfg)
if err != nil {
return fmt.Errorf("init updater config: %w", err)
}
binaryUpdates, err := updates.New(instance, "Binary Updater", *binaryUpdateConfig)
if err != nil {
return fmt.Errorf("configure binary updates: %w", err)
}
intelUpdates, err := updates.New(instance, "Intel Updater", *intelUpdateConfig)
if err != nil {
return fmt.Errorf("configure intel updates: %w", err)
}
// Force update all.
binErr := binaryUpdates.ForceUpdate()
if binErr != nil {
slog.Error("binary update failed", "err", binErr)
}
intelErr := intelUpdates.ForceUpdate()
if intelErr != nil {
slog.Error("intel update failed", "err", intelErr)
}
// Return error.
if binErr != nil {
return fmt.Errorf("binary update failed: %w", binErr)
}
if intelErr != nil {
return fmt.Errorf("intel update failed: %w", intelErr)
}
return nil
}
type updateDummyInstance struct{}
func (udi *updateDummyInstance) Restart() {}
func (udi *updateDummyInstance) Shutdown() {}
func (udi *updateDummyInstance) Notifications() *notifications.Notifications { return nil }

6
cmds/portmaster-start/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# binaries
portmaster-start
portmaster-start.exe
# test dir
test

77
cmds/portmaster-start/build Executable file
View file

@ -0,0 +1,77 @@
#!/bin/bash
# get build data
if [[ "$BUILD_COMMIT" == "" ]]; then
BUILD_COMMIT=$(git describe --all --long --abbrev=99 --dirty 2>/dev/null)
fi
if [[ "$BUILD_USER" == "" ]]; then
BUILD_USER=$(id -un)
fi
if [[ "$BUILD_HOST" == "" ]]; then
BUILD_HOST=$(hostname -f)
fi
if [[ "$BUILD_DATE" == "" ]]; then
BUILD_DATE=$(date +%d.%m.%Y)
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
BUILD_SOURCE=$(git remote -v | grep origin | cut -f2 | cut -d" " -f1 | head -n 1)
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
BUILD_SOURCE=$(git remote -v | cut -f2 | cut -d" " -f1 | head -n 1)
fi
BUILD_BUILDOPTIONS=$(echo $* | sed "s/ /§/g")
# check
if [[ "$BUILD_COMMIT" == "" ]]; then
echo "could not automatically determine BUILD_COMMIT, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_USER" == "" ]]; then
echo "could not automatically determine BUILD_USER, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_HOST" == "" ]]; then
echo "could not automatically determine BUILD_HOST, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_DATE" == "" ]]; then
echo "could not automatically determine BUILD_DATE, please supply manually as environment variable."
exit 1
fi
if [[ "$BUILD_SOURCE" == "" ]]; then
echo "could not automatically determine BUILD_SOURCE, please supply manually as environment variable."
exit 1
fi
# set build options
export CGO_ENABLED=0
# special handling for Windows
EXTRA_LD_FLAGS=""
if [[ $GOOS == "windows" ]]; then
# checks
if [[ $CC_FOR_windows_amd64 == "" ]]; then
echo "ENV variable CC_FOR_windows_amd64 (c compiler) is not set. Please set it to the cross compiler you want to use for compiling for windows_amd64"
exit 1
fi
if [[ $CXX_FOR_windows_amd64 == "" ]]; then
echo "ENV variable CXX_FOR_windows_amd64 (c++ compiler) is not set. Please set it to the cross compiler you want to use for compiling for windows_amd64"
exit 1
fi
# compilers
export CC=$CC_FOR_windows_amd64
export CXX=$CXX_FOR_windows_amd64
# custom
export CGO_ENABLED=1
EXTRA_LD_FLAGS='-H windowsgui' # Hide console window by default (but we attach to parent console if available)
# generate resource.syso for windows metadata / icon
go generate
fi
echo "Please notice, that this build script includes metadata into the build."
echo "This information is useful for debugging and license compliance."
echo "Run the compiled binary with the -version flag to see the information included."
# build
BUILD_PATH="github.com/safing/portmaster/base/info"
go build -ldflags "$EXTRA_LD_FLAGS -X ${BUILD_PATH}.commit=${BUILD_COMMIT} -X ${BUILD_PATH}.buildOptions=${BUILD_BUILDOPTIONS} -X ${BUILD_PATH}.buildUser=${BUILD_USER} -X ${BUILD_PATH}.buildHost=${BUILD_HOST} -X ${BUILD_PATH}.buildDate=${BUILD_DATE} -X ${BUILD_PATH}.buildSource=${BUILD_SOURCE}" "$@"

View file

@ -0,0 +1,11 @@
//go:build !windows
package main
import "os/exec"
func attachToParentConsole() (attached bool, err error) {
return true, nil
}
func hideWindow(cmd *exec.Cmd) {}

View file

@ -0,0 +1,150 @@
package main
// Parts of this file are FORKED
// from https://github.com/apenwarr/fixconsole/blob/35b2e7d921eb80a71a5f04f166ff0a1405bddf79/fixconsole_windows.go
// on 16.07.2019
// with Apache-2.0 license
// authored by https://github.com/apenwarr
// docs/sources:
// Stackoverflow Question: https://stackoverflow.com/questions/23743217/printing-output-to-a-command-window-when-golang-application-is-compiled-with-ld
// MS AttachConsole: https://docs.microsoft.com/en-us/windows/console/attachconsole
import (
"log"
"os"
"os/exec"
"syscall"
"golang.org/x/sys/windows"
)
const (
windowsAttachParentProcess = ^uintptr(0) // (DWORD)-1
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procAttachConsole = kernel32.NewProc("AttachConsole")
)
// Windows console output is a mess.
//
// If you compile as "-H windows", then if you launch your program without
// a console, Windows forcibly creates one to use as your stdin/stdout, which
// is silly for a GUI app, so we can't do that.
//
// If you compile as "-H windowsgui", then it doesn't create a console for
// your app... but also doesn't provide a working stdin/stdout/stderr even if
// you *did* launch from the console. However, you can use AttachConsole()
// to get a handle to your parent process's console, if any, and then
// os.NewFile() to turn that handle into a fd usable as stdout/stderr.
//
// However, then you have the problem that if you redirect stdout or stderr
// from the shell, you end up ignoring the redirection by forcing it to the
// console.
//
// To fix *that*, we have to detect whether there was a pre-existing stdout
// or not. We can check GetStdHandle(), which returns 0 for "should be
// console" and nonzero for "already pointing at a file."
//
// Be careful though! As soon as you run AttachConsole(), it resets *all*
// the GetStdHandle() handles to point them at the console instead, thus
// throwing away the original file redirects. So we have to GetStdHandle()
// *before* AttachConsole().
//
// For some reason, powershell redirections provide a valid file handle, but
// writing to that handle doesn't write to the file. I haven't found a way
// to work around that. (Windows 10.0.17763.379)
//
// Net result is as follows.
// Before:
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe broken works
// powershell broken broken
// WSL bash broken works
// After
// SHELL NON-REDIRECTED REDIRECTED
// explorer.exe no console n/a
// cmd.exe works works
// powershell works broken
// WSL bash works works
//
// We don't seem to make anything worse, at least.
func attachToParentConsole() (attached bool, err error) {
// get std handles before we attempt to attach to parent console
stdin, _ := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
stdout, _ := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
stderr, _ := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
// attempt to attach to parent console
err = procAttachConsole.Find()
if err != nil {
return false, err
}
r1, _, _ := procAttachConsole.Call(windowsAttachParentProcess)
if r1 == 0 {
// possible errors:
// ERROR_ACCESS_DENIED: already attached to console
// ERROR_INVALID_HANDLE: process does not have console
// ERROR_INVALID_PARAMETER: process does not exist
return false, nil
}
// get std handles after we attached to console
var invalid syscall.Handle
con := invalid
if stdin == invalid {
stdin, _ = syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
}
if stdout == invalid {
stdout, _ = syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
con = stdout
}
if stderr == invalid {
stderr, _ = syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
con = stderr
}
// correct output mode
if con != invalid {
// Make sure the console is configured to convert
// \n to \r\n, like Go programs expect.
h := windows.Handle(con)
var st uint32
err := windows.GetConsoleMode(h, &st)
if err != nil {
log.Printf("failed to get console mode: %s\n", err)
} else {
err = windows.SetConsoleMode(h, st&^windows.DISABLE_NEWLINE_AUTO_RETURN)
if err != nil {
log.Printf("failed to set console mode: %s\n", err)
}
}
}
// fix std handles to correct values (ie. redirects)
if stdin != invalid {
os.Stdin = os.NewFile(uintptr(stdin), "stdin")
log.Printf("fixed os.Stdin after attaching to parent console\n")
}
if stdout != invalid {
os.Stdout = os.NewFile(uintptr(stdout), "stdout")
log.Printf("fixed os.Stdout after attaching to parent console\n")
}
if stderr != invalid {
os.Stderr = os.NewFile(uintptr(stderr), "stderr")
log.Printf("fixed os.Stderr after attaching to parent console\n")
}
log.Println("attached to parent console")
return true, nil
}
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}

View file

@ -0,0 +1,42 @@
package main
import (
"fmt"
"log"
"os"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(cleanStructureCmd)
}
var cleanStructureCmd = &cobra.Command{
Use: "clean-structure",
Short: "Create and clean the required directory structure",
RunE: func(cmd *cobra.Command, args []string) error {
if err := ensureLoggingDir(); err != nil {
return err
}
return cleanAndEnsureExecDir()
},
}
func cleanAndEnsureExecDir() error {
execDir := dataRoot.ChildDir("exec", 0o777)
// Clean up and remove exec dir.
err := os.RemoveAll(execDir.Path)
if err != nil {
log.Printf("WARNING: failed to fully remove exec dir (%q) for cleaning: %s", execDir.Path, err)
}
// Re-create exec dir.
err = execDir.Ensure()
if err != nil {
return fmt.Errorf("failed to initialize exec dir (%q): %w", execDir.Path, err)
}
return nil
}

View file

@ -0,0 +1,180 @@
package main
// Based on the official Go examples from
// https://github.com/golang/sys/blob/master/windows/svc/example
// by The Go Authors.
// Original LICENSE (sha256sum: 2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067) can be found in vendor/pkg cache directory.
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
func init() {
rootCmd.AddCommand(installCmd)
installCmd.AddCommand(installService)
rootCmd.AddCommand(uninstallCmd)
uninstallCmd.AddCommand(uninstallService)
}
var installCmd = &cobra.Command{
Use: "install",
Short: "Install system integrations",
}
var uninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "Uninstall system integrations",
}
var installService = &cobra.Command{
Use: "core-service",
Short: "Install Portmaster Core Windows Service",
RunE: installWindowsService,
}
var uninstallService = &cobra.Command{
Use: "core-service",
Short: "Uninstall Portmaster Core Windows Service",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// non-nil dummy to override db flag requirement
return nil
},
RunE: uninstallWindowsService,
}
func getAbsBinaryPath() (string, error) {
p, err := filepath.Abs(os.Args[0])
if err != nil {
return "", err
}
return p, nil
}
func getServiceExecCommand(exePath string, escape bool) []string {
return []string{
maybeEscape(exePath, escape),
"core-service",
"--data",
maybeEscape(dataRoot.Path, escape),
"--input-signals",
}
}
func maybeEscape(s string, escape bool) string {
if escape {
return windows.EscapeArg(s)
}
return s
}
func getServiceConfig(exePath string) mgr.Config {
return mgr.Config{
ServiceType: windows.SERVICE_WIN32_OWN_PROCESS,
StartType: mgr.StartAutomatic,
ErrorControl: mgr.ErrorNormal,
BinaryPathName: strings.Join(getServiceExecCommand(exePath, true), " "),
DisplayName: "Portmaster Core",
Description: "Portmaster Application Firewall - Core Service",
}
}
func getRecoveryActions() (recoveryActions []mgr.RecoveryAction, resetPeriod uint32) {
return []mgr.RecoveryAction{
{
Type: mgr.ServiceRestart, // one of NoAction, ComputerReboot, ServiceRestart or RunCommand
Delay: 1 * time.Minute, // the time to wait before performing the specified action
},
}, 86400
}
func installWindowsService(cmd *cobra.Command, args []string) error {
// get exe path
exePath, err := getAbsBinaryPath()
if err != nil {
return fmt.Errorf("failed to get exe path: %s", err)
}
// connect to Windows service manager
m, err := mgr.Connect()
if err != nil {
return fmt.Errorf("failed to connect to service manager: %s", err)
}
defer m.Disconnect() //nolint:errcheck // TODO
// open service
created := false
s, err := m.OpenService(serviceName)
if err != nil {
// create service
cmd := getServiceExecCommand(exePath, false)
s, err = m.CreateService(serviceName, cmd[0], getServiceConfig(exePath), cmd[1:]...)
if err != nil {
return fmt.Errorf("failed to create service: %s", err)
}
defer s.Close()
created = true
} else {
// update service
err = s.UpdateConfig(getServiceConfig(exePath))
if err != nil {
return fmt.Errorf("failed to update service: %s", err)
}
defer s.Close()
}
// update recovery actions
err = s.SetRecoveryActions(getRecoveryActions())
if err != nil {
return fmt.Errorf("failed to update recovery actions: %s", err)
}
if created {
log.Printf("created service %s\n", serviceName)
} else {
log.Printf("updated service %s\n", serviceName)
}
return nil
}
func uninstallWindowsService(cmd *cobra.Command, args []string) error {
// connect to Windows service manager
m, err := mgr.Connect()
if err != nil {
return err
}
defer m.Disconnect() //nolint:errcheck // we don't care if we failed to disconnect from the service manager, we're quitting anyway.
// open service
s, err := m.OpenService(serviceName)
if err != nil {
return fmt.Errorf("service %s is not installed", serviceName)
}
defer s.Close()
_, err = s.Control(svc.Stop)
if err != nil {
log.Printf("failed to stop service: %s\n", err)
}
// delete service
err = s.Delete()
if err != nil {
return fmt.Errorf("failed to delete service: %s", err)
}
log.Printf("uninstalled service %s\n", serviceName)
return nil
}

View file

@ -0,0 +1,109 @@
package main
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
processInfo "github.com/shirou/gopsutil/process"
)
func checkAndCreateInstanceLock(path, name string, perUser bool) (pid int32, err error) {
lockFilePath := getLockFilePath(path, name, perUser)
// read current pid file
data, err := os.ReadFile(lockFilePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// create new lock
return 0, createInstanceLock(lockFilePath)
}
return 0, err
}
// file exists!
parsedPid, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err != nil {
log.Printf("failed to parse existing lock pid file (ignoring): %s\n", err)
return 0, createInstanceLock(lockFilePath)
}
// Check if process exists.
p, err := processInfo.NewProcess(int32(parsedPid))
switch {
case err == nil:
// Process exists, continue.
case errors.Is(err, processInfo.ErrorProcessNotRunning):
// A process with the locked PID does not exist.
// This is expected, so we can continue normally.
return 0, createInstanceLock(lockFilePath)
default:
// There was an internal error getting the process.
return 0, err
}
// Get the process paths and evaluate and clean them.
executingBinaryPath, err := p.Exe()
if err != nil {
return 0, fmt.Errorf("failed to get path of existing process: %w", err)
}
cleanedExecutingBinaryPath, err := filepath.EvalSymlinks(executingBinaryPath)
if err != nil {
return 0, fmt.Errorf("failed to evaluate path of existing process: %w", err)
}
// Check if the binary is portmaster-start with high probability.
if !strings.Contains(filepath.Base(cleanedExecutingBinaryPath), "portmaster-start") {
// The process with the locked PID belongs to another binary.
// As the Portmaster usually starts very early, it will have a low PID,
// which could be assigned to another process on next boot.
return 0, createInstanceLock(lockFilePath)
}
// Return PID of already running instance.
return p.Pid, nil
}
func createInstanceLock(lockFilePath string) error {
// check data root dir
err := dataRoot.Ensure()
if err != nil {
log.Printf("failed to check data root dir: %s\n", err)
}
// create lock file
// TODO: Investigate required permissions.
err = os.WriteFile(lockFilePath, []byte(strconv.Itoa(os.Getpid())), 0o0666) //nolint:gosec
if err != nil {
return err
}
return nil
}
func deleteInstanceLock(path, name string, perUser bool) error {
return os.Remove(getLockFilePath(path, name, perUser))
}
func getLockFilePath(path, name string, perUser bool) string {
if !perUser {
return filepath.Join(dataRoot.Path, path, fmt.Sprintf("%s-lock.pid", name))
}
// Get user ID for per-user lock file.
var userID string
usr, err := user.Current()
if err != nil {
log.Printf("failed to get current user: %s\n", err)
userID = "no-user"
} else {
userID = usr.Uid
}
return filepath.Join(dataRoot.Path, path, fmt.Sprintf("%s-%s-lock.pid", name, userID))
}

View file

@ -0,0 +1,127 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"time"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/database/record"
"github.com/safing/portmaster/base/info"
"github.com/safing/structures/container"
"github.com/safing/structures/dsd"
)
func initializeLogFile(logFilePath string, identifier string, version string) *os.File {
logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE, 0o0440) //nolint:gosec // As desired.
if err != nil {
log.Printf("failed to create log file %s: %s\n", logFilePath, err)
return nil
}
// create header, so that the portmaster can view log files as a database
meta := record.Meta{}
meta.Update()
meta.SetAbsoluteExpiry(time.Now().Add(720 * time.Hour).Unix()) // one month
// manually marshal
// version
c := container.New([]byte{1})
// meta
metaSection, err := dsd.Dump(meta, dsd.JSON)
if err != nil {
log.Printf("failed to serialize header for log file %s: %s\n", logFilePath, err)
finalizeLogFile(logFile)
return nil
}
c.AppendAsBlock(metaSection)
// log file data type (string) and newline for better manual viewing
c.Append([]byte("S\n"))
c.Append([]byte(fmt.Sprintf("executing %s version %s on %s %s\n", identifier, version, runtime.GOOS, runtime.GOARCH)))
_, err = logFile.Write(c.CompileData())
if err != nil {
log.Printf("failed to write header for log file %s: %s\n", logFilePath, err)
finalizeLogFile(logFile)
return nil
}
return logFile
}
func finalizeLogFile(logFile *os.File) {
logFilePath := logFile.Name()
err := logFile.Close()
if err != nil {
log.Printf("failed to close log file %s: %s\n", logFilePath, err)
}
// check file size
stat, err := os.Stat(logFilePath)
if err != nil {
return
}
// delete if file is smaller than
if stat.Size() >= 200 { // header + info is about 150 bytes
return
}
if err := os.Remove(logFilePath); err != nil {
log.Printf("failed to delete empty log file %s: %s\n", logFilePath, err)
}
}
func getLogFile(options *Options, version, ext string) *os.File {
// check logging dir
logFileBasePath := filepath.Join(logsRoot.Path, options.ShortIdentifier)
err := logsRoot.EnsureAbsPath(logFileBasePath)
if err != nil {
log.Printf("failed to check/create log file folder %s: %s\n", logFileBasePath, err)
}
// open log file
logFilePath := filepath.Join(logFileBasePath, fmt.Sprintf("%s%s", time.Now().UTC().Format("2006-01-02-15-04-05"), ext))
return initializeLogFile(logFilePath, options.Identifier, version)
}
func getPmStartLogFile(ext string) *os.File {
return getLogFile(&Options{
ShortIdentifier: "start",
Identifier: "start/portmaster-start",
}, info.Version(), ext)
}
//nolint:unused // false positive on linux, currently used by windows only. TODO: move to a _windows file.
func logControlError(cErr error) {
// check if error present
if cErr == nil {
return
}
errorFile := getPmStartLogFile(".error.log")
if errorFile == nil {
return
}
defer func() {
_ = errorFile.Close()
}()
fmt.Fprintln(errorFile, cErr.Error())
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only. TODO: move to a _windows file.
func runAndLogControlError(wrappedFunc func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
err := wrappedFunc(cmd, args)
if err != nil {
logControlError(err)
}
return err
}
}

View file

@ -0,0 +1,257 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/dataroot"
"github.com/safing/portmaster/base/info"
portlog "github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/updater"
"github.com/safing/portmaster/base/utils"
"github.com/safing/portmaster/service/updates/helper"
)
var (
dataDir string
maxRetries int
dataRoot *utils.DirStructure
logsRoot *utils.DirStructure
forceOldUI bool
updateURLFlag string
userAgentFlag string
// Create registry.
registry = &updater.ResourceRegistry{
Name: "updates",
UpdateURLs: []string{
"https://updates.safing.io",
},
UserAgent: fmt.Sprintf("Portmaster Start (%s %s)", runtime.GOOS, runtime.GOARCH),
Verification: helper.VerificationConfig,
DevMode: false,
Online: true, // is disabled later based on command
}
rootCmd = &cobra.Command{
Use: "portmaster-start",
Short: "Start Portmaster components",
PersistentPreRunE: func(cmd *cobra.Command, args []string) (err error) {
mustLoadIndex := indexRequired(cmd)
if err := configureRegistry(mustLoadIndex); err != nil {
return err
}
if err := ensureLoggingDir(); err != nil {
return err
}
return nil
},
SilenceUsage: true,
}
)
func init() {
// Let cobra ignore if we are running as "GUI" or not
cobra.MousetrapHelpText = ""
flags := rootCmd.PersistentFlags()
{
flags.StringVar(&dataDir, "data", "", "Configures the data directory. Alternatively, this can also be set via the environment variable PORTMASTER_DATA.")
flags.StringVar(&updateURLFlag, "update-server", "", "Set an alternative update server (full URL)")
flags.StringVar(&userAgentFlag, "update-agent", "", "Set an alternative user agent for requests to the update server")
flags.IntVar(&maxRetries, "max-retries", 5, "Maximum number of retries when starting a Portmaster component")
flags.BoolVar(&stdinSignals, "input-signals", false, "Emulate signals using stdin.")
flags.BoolVar(&forceOldUI, "old-ui", false, "Use the old ui. (Beta)")
_ = rootCmd.MarkPersistentFlagDirname("data")
_ = flags.MarkHidden("input-signals")
}
}
func main() {
cobra.OnInitialize(initCobra)
// set meta info
info.Set("Portmaster Start", "", "GPLv3")
// catch interrupt for clean shutdown
signalCh := make(chan os.Signal, 2)
signal.Notify(
signalCh,
os.Interrupt,
syscall.SIGHUP,
syscall.SIGINT,
syscall.SIGTERM,
syscall.SIGQUIT,
)
// start root command
go func() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
os.Exit(0)
}()
// wait for signals
for sig := range signalCh {
if childIsRunning.IsSet() {
log.Printf("got %s signal (ignoring), waiting for child to exit...\n", sig)
continue
}
log.Printf("got %s signal, exiting... (not executing anything)\n", sig)
os.Exit(0)
}
}
func initCobra() {
// check if we are running in a console (try to attach to parent console if available)
var err error
runningInConsole, err = attachToParentConsole()
if err != nil {
log.Fatalf("failed to attach to parent console: %s\n", err)
}
// check if meta info is ok
err = info.CheckVersion()
if err != nil {
log.Fatalf("compile error: please compile using the provided build script")
}
// set up logging
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
log.SetPrefix("[pmstart] ")
log.SetOutput(os.Stdout)
// not using portbase logger
portlog.SetLogLevel(portlog.CriticalLevel)
}
func configureRegistry(mustLoadIndex bool) error {
// Check if update server URL supplied via flag is a valid URL.
if updateURLFlag != "" {
u, err := url.Parse(updateURLFlag)
if err != nil {
return fmt.Errorf("supplied update server URL is invalid: %w", err)
}
if u.Scheme != "https" {
return errors.New("supplied update server URL must use HTTPS")
}
}
// Override values from flags.
if userAgentFlag != "" {
registry.UserAgent = userAgentFlag
}
if updateURLFlag != "" {
registry.UpdateURLs = []string{updateURLFlag}
}
// If dataDir is not set, check the environment variable.
if dataDir == "" {
dataDir = os.Getenv("PORTMASTER_DATA")
}
// If it's still empty, try to auto-detect it.
if dataDir == "" {
dataDir = detectInstallationDir()
}
// Finally, if it's still empty, the user must provide it.
if dataDir == "" {
return errors.New("please set the data directory using --data=/path/to/data/dir")
}
// Remove left over quotes.
dataDir = strings.Trim(dataDir, `\"`)
// Initialize data root.
err := dataroot.Initialize(dataDir, 0o0755)
if err != nil {
return fmt.Errorf("failed to initialize data root: %w", err)
}
dataRoot = dataroot.Root()
// Initialize registry.
err = registry.Initialize(dataRoot.ChildDir("updates", 0o0755))
if err != nil {
return err
}
return updateRegistryIndex(mustLoadIndex)
}
func ensureLoggingDir() error {
// set up logs root
logsRoot = dataRoot.ChildDir("logs", 0o0777)
err := logsRoot.Ensure()
if err != nil {
return fmt.Errorf("failed to initialize logs root (%q): %w", logsRoot.Path, err)
}
// warn about CTRL-C on windows
if runningInConsole && onWindows {
log.Println("WARNING: portmaster-start is marked as a GUI application in order to get rid of the console window.")
log.Println("WARNING: CTRL-C will immediately kill without clean shutdown.")
}
return nil
}
func updateRegistryIndex(mustLoadIndex bool) error {
// Set indexes based on the release channel.
warning := helper.SetIndexes(registry, "", false, false, false)
if warning != nil {
log.Printf("WARNING: %s\n", warning)
}
// Load indexes from disk or network, if needed and desired.
err := registry.LoadIndexes(context.Background())
if err != nil {
log.Printf("WARNING: error loading indexes: %s\n", err)
if mustLoadIndex {
return err
}
}
// Load versions from disk to know which others we have and which are available.
err = registry.ScanStorage("")
if err != nil {
log.Printf("WARNING: error during storage scan: %s\n", err)
}
registry.SelectVersions()
return nil
}
func detectInstallationDir() string {
exePath, err := filepath.Abs(os.Args[0])
if err != nil {
return ""
}
parent := filepath.Dir(exePath)
stableJSONFile := filepath.Join(parent, "updates", "stable.json")
stat, err := os.Stat(stableJSONFile)
if err != nil {
return ""
}
if stat.IsDir() {
return ""
}
return parent
}

123
cmds/portmaster-start/pack Executable file
View file

@ -0,0 +1,123 @@
#!/bin/bash
baseDir="$( cd "$(dirname "$0")" && pwd )"
cd "$baseDir"
COL_OFF="\033[0m"
COL_BOLD="\033[01;01m"
COL_RED="\033[31m"
COL_GREEN="\033[32m"
COL_YELLOW="\033[33m"
destDirPart1="../../dist"
destDirPart2="start"
function prep {
# output
output="portmaster-start"
# get version
version=$(grep "info.Set" main.go | cut -d'"' -f4)
# build versioned file name
filename="portmaster-start_v${version//./-}"
# platform
platform="${GOOS}_${GOARCH}"
if [[ $GOOS == "windows" ]]; then
filename="${filename}.exe"
output="${output}.exe"
fi
# build destination path
destPath=${destDirPart1}/${platform}/${destDirPart2}/$filename
}
function check {
prep
# check if file exists
if [[ -f $destPath ]]; then
echo "[start] $platform $version already built"
else
echo -e "${COL_BOLD}[start] $platform v$version${COL_OFF}"
fi
}
function build {
prep
# check if file exists
if [[ -f $destPath ]]; then
echo "[start] $platform already built in v$version, skipping..."
return
fi
# build
./build
if [[ $? -ne 0 ]]; then
echo -e "\n${COL_BOLD}[start] $platform v$version: ${COL_RED}BUILD FAILED.${COL_OFF}"
exit 1
fi
mkdir -p $(dirname $destPath)
cp $output $destPath
echo -e "\n${COL_BOLD}[start] $platform v$version: ${COL_GREEN}successfully built.${COL_OFF}"
}
function reset {
prep
# delete if file exists
if [[ -f $destPath ]]; then
rm $destPath
echo "[start] $platform v$version deleted."
fi
}
function check_all {
GOOS=linux GOARCH=amd64 check
GOOS=windows GOARCH=amd64 check
GOOS=darwin GOARCH=amd64 check
GOOS=linux GOARCH=arm64 check
GOOS=windows GOARCH=arm64 check
GOOS=darwin GOARCH=arm64 check
}
function build_all {
GOOS=linux GOARCH=amd64 build
GOOS=windows GOARCH=amd64 build
GOOS=darwin GOARCH=amd64 build
GOOS=linux GOARCH=arm64 build
GOOS=windows GOARCH=arm64 build
GOOS=darwin GOARCH=arm64 build
}
function reset_all {
GOOS=linux GOARCH=amd64 reset
GOOS=windows GOARCH=amd64 reset
GOOS=darwin GOARCH=amd64 reset
GOOS=linux GOARCH=arm64 reset
GOOS=windows GOARCH=arm64 reset
GOOS=darwin GOARCH=arm64 reset
}
case $1 in
"check" )
check_all
;;
"build" )
build_all
;;
"reset" )
reset_all
;;
* )
echo ""
echo "build list:"
echo ""
check_all
echo ""
read -p "press [Enter] to start building" x
echo ""
build_all
echo ""
echo "finished building."
echo ""
;;
esac

View file

@ -0,0 +1,82 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"github.com/safing/portmaster/service/firewall/interception"
)
var recoverIPTablesCmd = &cobra.Command{
Use: "recover-iptables",
Short: "Removes obsolete IP tables rules in case of an unclean shutdown",
RunE: func(*cobra.Command, []string) error {
// interception.DeactiveNfqueueFirewall uses coreos/go-iptables
// which shells out to the /sbin/iptables binary. As a result,
// we don't get the errno of the actual error and need to parse the
// output instead. Make sure it's always english by setting LC_ALL=C
currentLocale := os.Getenv("LC_ALL")
_ = os.Setenv("LC_ALL", "C")
defer func() {
_ = os.Setenv("LC_ALL", currentLocale)
}()
err := interception.DeactivateNfqueueFirewall()
if err == nil {
return nil
}
// we don't want to show ErrNotExists to the user
// as that only means portmaster did the cleanup itself.
var mr *multierror.Error
if !errors.As(err, &mr) {
return err
}
var filteredErrors *multierror.Error
for _, err := range mr.Errors {
// if we have a permission denied error, all errors will be the same
if strings.Contains(err.Error(), "Permission denied") {
return fmt.Errorf("failed to cleanup iptables: %w", os.ErrPermission)
}
if !strings.Contains(err.Error(), "No such file or directory") {
filteredErrors = multierror.Append(filteredErrors, err)
}
}
if filteredErrors != nil {
filteredErrors.ErrorFormat = formatNfqErrors
return filteredErrors.ErrorOrNil()
}
return nil
},
SilenceUsage: true,
}
func init() {
rootCmd.AddCommand(recoverIPTablesCmd)
}
func formatNfqErrors(es []error) string {
if len(es) == 1 {
return fmt.Sprintf("1 error occurred:\n\t* %s\n\n", es[0])
}
points := make([]string, len(es))
for i, err := range es {
// only display the very first line of each error
first := strings.Split(err.Error(), "\n")[0]
points[i] = fmt.Sprintf("* %s", first)
}
return fmt.Sprintf(
"%d errors occurred:\n\t%s\n\n",
len(es), strings.Join(points, "\n\t"))
}

View file

@ -0,0 +1,486 @@
package main
import (
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/tevino/abool"
"github.com/safing/portmaster/service/updates/helper"
)
const (
// RestartExitCode is the exit code that any service started by portmaster-start
// can return in order to trigger a restart after a clean shutdown.
RestartExitCode = 23
// ControlledFailureExitCode is the exit code that any service started by
// portmaster-start can return in order to signify a controlled failure.
// This disables retrying and exits with an error code.
ControlledFailureExitCode = 24
// StartOldUIExitCode is an exit code that is returned by the UI when there. This is manfully triaged by the user, if the new UI does not work for them.
StartOldUIExitCode = 77
MissingDependencyExitCode = 0xc0000135 // Windows STATUS_DLL_NOT_FOUND
exeSuffix = ".exe"
zipSuffix = ".zip"
)
var (
runningInConsole bool
onWindows = runtime.GOOS == "windows"
stdinSignals bool
childIsRunning = abool.NewBool(false)
fallBackToOldUI bool = false
)
// Options for starting component.
type Options struct {
Name string
Identifier string // component identifier
ShortIdentifier string // populated automatically
LockPathPrefix string
LockPerUser bool
PIDFile bool
SuppressArgs bool // do not use any args
AllowDownload bool // allow download of component if it is not yet available
AllowHidingWindow bool // allow hiding the window of the subprocess
NoOutput bool // do not use stdout/err if logging to file is available (did not fail to open log file)
RestartOnFail bool // Try restarting automatically, if the started component fails.
}
// This is a temp value that will be used to test the new UI in beta.
var app2Options = Options{
Name: "Portmaster App2",
Identifier: "app2/portmaster-app",
AllowDownload: false,
AllowHidingWindow: false,
RestartOnFail: true,
}
func init() {
// Make sure the new UI has a proper extension.
if onWindows {
app2Options.Identifier += ".zip"
}
registerComponent([]Options{
{
Name: "Portmaster Core",
Identifier: "core/portmaster-core",
AllowDownload: true,
AllowHidingWindow: true,
PIDFile: true,
RestartOnFail: true,
},
{
Name: "Portmaster App",
Identifier: "app/portmaster-app.zip",
AllowDownload: false,
AllowHidingWindow: false,
RestartOnFail: true,
},
{
Name: "Portmaster Notifier",
Identifier: "notifier/portmaster-notifier",
LockPerUser: true,
AllowDownload: false,
AllowHidingWindow: true,
PIDFile: true,
LockPathPrefix: "exec",
},
{
Name: "Safing Privacy Network",
Identifier: "hub/spn-hub",
AllowDownload: true,
AllowHidingWindow: true,
PIDFile: true,
RestartOnFail: true,
},
app2Options,
})
}
func registerComponent(opts []Options) {
for idx := range opts {
opt := &opts[idx] // we need a copy
if opt.ShortIdentifier == "" {
opt.ShortIdentifier = path.Dir(opt.Identifier)
}
rootCmd.AddCommand(
&cobra.Command{
Use: opt.ShortIdentifier,
Short: "Run the " + opt.Name,
RunE: func(cmd *cobra.Command, args []string) error {
err := run(opt, args)
initiateShutdown(err)
return err
},
},
)
showCmd.AddCommand(
&cobra.Command{
Use: opt.ShortIdentifier,
Short: "Show command to execute the " + opt.Name,
RunE: func(cmd *cobra.Command, args []string) error {
return show(opt, args)
},
},
)
}
}
func getExecArgs(opts *Options, cmdArgs []string) []string {
if opts.SuppressArgs {
return nil
}
args := []string{"--data", dataDir}
if stdinSignals {
args = append(args, "--input-signals")
}
if runtime.GOOS == "linux" && opts.ShortIdentifier == "app" {
// see https://www.freedesktop.org/software/systemd/man/pam_systemd.html#type=
if xdgSessionType := os.Getenv("XDG_SESSION_TYPE"); xdgSessionType == "wayland" {
// we're running the Portmaster UI App under Wayland so make sure we add some arguments
// required by Electron.
args = append(args,
[]string{
"--enable-features=UseOzonePlatform,WaylandWindowDecorations",
"--ozone-platform=wayland",
}...,
)
}
}
args = append(args, cmdArgs...)
return args
}
func run(opts *Options, cmdArgs []string) (err error) {
// set download option
registry.Online = opts.AllowDownload
if isShuttingDown() {
return nil
}
// check for duplicate instances
if opts.PIDFile {
pid, err := checkAndCreateInstanceLock(opts.LockPathPrefix, opts.ShortIdentifier, opts.LockPerUser)
if err != nil {
return fmt.Errorf("failed to exec lock: %w", err)
}
if pid != 0 {
return fmt.Errorf("another instance of %s is already running: PID %d", opts.Name, pid)
}
defer func() {
err := deleteInstanceLock(opts.LockPathPrefix, opts.ShortIdentifier, opts.LockPerUser)
if err != nil {
log.Printf("failed to delete instance lock: %s\n", err)
}
}()
}
// notify service after some time
go func() {
// assume that after 3 seconds service has finished starting
time.Sleep(3 * time.Second)
startupComplete <- struct{}{}
}()
// adapt identifier
if onWindows && !strings.HasSuffix(opts.Identifier, zipSuffix) {
opts.Identifier += exeSuffix
}
// setup logging
// init log file
logFile := getPmStartLogFile(".log")
if logFile != nil {
// don't close logFile, will be closed by system
if opts.NoOutput {
log.Println("disabling log output to stdout... bye!")
log.SetOutput(logFile)
} else {
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
}
}
return runAndRestart(opts, cmdArgs)
}
func runAndRestart(opts *Options, args []string) error {
tries := 0
for {
tryAgain, err := execute(opts, args)
if err != nil {
log.Printf("%s failed with: %s\n", opts.Identifier, err)
tries++
if tries >= maxRetries {
log.Printf("encountered %d consecutive errors, giving up ...", tries)
return err
}
} else {
tries = 0
log.Printf("%s exited without error", opts.Identifier)
}
if !opts.RestartOnFail || !tryAgain {
return err
}
// if a restart was requested `tries` is set to 0 so
// this becomes a no-op.
time.Sleep(time.Duration(2*tries) * time.Second)
if tries >= 2 || err == nil {
// if we are constantly failing or a restart was requested
// try to update the resources.
log.Printf("updating registry index")
_ = updateRegistryIndex(false) // will always return nil
}
}
}
func fixExecPerm(path string) error {
if onWindows {
return nil
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("failed to stat %s: %w", path, err)
}
if info.Mode() == 0o0755 {
return nil
}
if err := os.Chmod(path, 0o0755); err != nil { //nolint:gosec // Set execution rights.
return fmt.Errorf("failed to chmod %s: %w", path, err)
}
return nil
}
func copyLogs(opts *Options, consoleSink io.Writer, version, ext string, logSource io.Reader, notifier chan<- struct{}) {
defer func() { notifier <- struct{}{} }()
sink := consoleSink
fileSink := getLogFile(opts, version, ext)
if fileSink != nil {
defer finalizeLogFile(fileSink)
if opts.NoOutput {
sink = fileSink
} else {
sink = io.MultiWriter(consoleSink, fileSink)
}
}
if bytes, err := io.Copy(sink, logSource); err != nil {
log.Printf("%s: writing logs failed after %d bytes: %s", fileSink.Name(), bytes, err)
}
}
func persistOutputStreams(opts *Options, version string, cmd *exec.Cmd) (chan struct{}, error) {
var (
done = make(chan struct{})
copyNotifier = make(chan struct{}, 2)
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stderr: %w", err)
}
go copyLogs(opts, os.Stdout, version, ".log", stdout, copyNotifier)
go copyLogs(opts, os.Stderr, version, ".error.log", stderr, copyNotifier)
go func() {
<-copyNotifier
<-copyNotifier
close(copyNotifier)
close(done)
}()
return done, nil
}
func execute(opts *Options, args []string) (cont bool, err error) {
// Auto-upgrade to new UI if in beta and new UI is not disabled or failed.
if opts.ShortIdentifier == "app" &&
registry.UsePreReleases &&
!forceOldUI &&
!fallBackToOldUI {
log.Println("auto-upgraded to new UI")
opts = &app2Options
}
// Compile arguments and add additional arguments based on system configuration.
// Extra parameters can be specified using "-- --some-parameter".
args = getExecArgs(opts, args)
file, err := registry.GetFile(
helper.PlatformIdentifier(opts.Identifier),
)
if err != nil {
return true, fmt.Errorf("could not get component: %w", err)
}
binPath := file.Path()
// Adapt path for packaged software.
if strings.HasSuffix(binPath, zipSuffix) {
// Remove suffix from binary path.
binPath = strings.TrimSuffix(binPath, zipSuffix)
// Add binary with the same name to access the unpacked binary.
binPath = filepath.Join(binPath, filepath.Base(binPath))
// Adapt binary path on Windows.
if onWindows {
binPath += exeSuffix
}
}
// check permission
if err := fixExecPerm(binPath); err != nil {
return true, err
}
log.Printf("starting %s %s\n", binPath, strings.Join(args, " "))
// create command
exc := exec.Command(binPath, args...)
if !runningInConsole && opts.AllowHidingWindow {
// Windows only:
// only hide (all) windows of program if we are not running in console and windows may be hidden
hideWindow(exc)
}
outputsWritten, err := persistOutputStreams(opts, file.Version(), exc)
if err != nil {
return true, err
}
interrupt, err := getProcessSignalFunc(exc)
if err != nil {
return true, err
}
err = exc.Start()
if err != nil {
return true, fmt.Errorf("failed to start %s: %w", opts.Identifier, err)
}
childIsRunning.Set()
// wait for completion
finished := make(chan error, 1)
go func() {
defer close(finished)
<-outputsWritten
// wait for process to return
finished <- exc.Wait()
// update status
childIsRunning.UnSet()
}()
// state change listeners
select {
case <-shuttingDown:
if err := interrupt(); err != nil {
log.Printf("failed to signal %s to shutdown: %s\n", opts.Identifier, err)
err = exc.Process.Kill()
if err != nil {
return false, fmt.Errorf("failed to kill %s: %w", opts.Identifier, err)
}
return false, fmt.Errorf("killed %s", opts.Identifier)
}
// wait until shut down
select {
case <-finished:
case <-time.After(3 * time.Minute): // portmaster core prints stack if not able to shutdown in 3 minutes, give it one more ...
err = exc.Process.Kill()
if err != nil {
return false, fmt.Errorf("failed to kill %s: %w", opts.Identifier, err)
}
return false, fmt.Errorf("killed %s", opts.Identifier)
}
return false, nil
case err := <-finished:
return parseExitError(err)
}
}
func getProcessSignalFunc(cmd *exec.Cmd) (func() error, error) {
if stdinSignals {
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to connect stdin: %w", err)
}
return func() error {
_, err := fmt.Fprintln(stdin, "SIGINT")
return err
}, nil
}
return func() error {
return cmd.Process.Signal(os.Interrupt)
}, nil
}
func parseExitError(err error) (restart bool, errWithCtx error) {
if err == nil {
// clean and coordinated exit
return false, nil
}
var exErr *exec.ExitError
if errors.As(err, &exErr) {
switch exErr.ProcessState.ExitCode() {
case 0:
return false, fmt.Errorf("clean exit with error: %w", err)
case 1:
return true, fmt.Errorf("error during execution: %w", err)
case RestartExitCode:
return true, nil
case ControlledFailureExitCode:
return false, errors.New("controlled failure, check logs")
case StartOldUIExitCode:
fallBackToOldUI = true
return true, errors.New("user requested old UI")
case MissingDependencyExitCode:
fallBackToOldUI = true
return true, errors.New("new UI failed with missing dependency")
default:
return true, fmt.Errorf("unknown exit code %w", exErr)
}
}
return true, fmt.Errorf("unexpected error type: %w", err)
}

View file

@ -0,0 +1,134 @@
package main
// Based on the official Go examples from
// https://github.com/golang/sys/blob/master/windows/svc/example
// by The Go Authors.
// Original LICENSE (sha256sum: 2d36597f7117c38b006835ae7f537487207d8ec407aa9d9980794b2030cbc067) can be found in vendor/pkg cache directory.
import (
"fmt"
"log"
"sync"
"time"
"github.com/spf13/cobra"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
)
var (
runCoreService = &cobra.Command{
Use: "core-service",
Short: "Run the Portmaster Core as a Windows Service",
RunE: runAndLogControlError(func(cmd *cobra.Command, args []string) error {
return runService(cmd, &Options{
Name: "Portmaster Core Service",
Identifier: "core/portmaster-core",
ShortIdentifier: "core",
AllowDownload: true,
AllowHidingWindow: false,
NoOutput: true,
RestartOnFail: true,
}, args)
}),
FParseErrWhitelist: cobra.FParseErrWhitelist{
// UnknownFlags will ignore unknown flags errors and continue parsing rest of the flags
UnknownFlags: true,
},
}
// wait groups
runWg sync.WaitGroup
finishWg sync.WaitGroup
)
func init() {
rootCmd.AddCommand(runCoreService)
}
const serviceName = "PortmasterCore"
type windowsService struct{}
func (ws *windowsService) Execute(args []string, changeRequests <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
service:
for {
select {
case <-startupComplete:
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
case <-shuttingDown:
changes <- svc.Status{State: svc.StopPending}
break service
case c := <-changeRequests:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
initiateShutdown(nil)
default:
log.Printf("unexpected control request: #%d\n", c)
}
}
}
// define return values
if getShutdownError() != nil {
ssec = true // this error is specific to this service (ie. custom)
errno = 1 // generic error, check logs / windows events
}
// wait until everything else is finished
finishWg.Wait()
// send stopped status
changes <- svc.Status{State: svc.Stopped}
// wait a little for the status to reach Windows
time.Sleep(100 * time.Millisecond)
return ssec, errno
}
func runService(_ *cobra.Command, opts *Options, cmdArgs []string) error {
// check if we are running interactively
isDebug, err := svc.IsAnInteractiveSession()
if err != nil {
return fmt.Errorf("could not determine if running interactively: %s", err)
}
// select service run type
svcRun := svc.Run
if isDebug {
log.Printf("WARNING: running interactively, switching to debug execution (no real service).\n")
svcRun = debug.Run
}
runWg.Add(2)
finishWg.Add(1)
// run service client
go func() {
sErr := svcRun(serviceName, &windowsService{})
initiateShutdown(sErr)
runWg.Done()
}()
// run service
go func() {
// run slightly delayed
time.Sleep(250 * time.Millisecond)
err := run(opts, getExecArgs(opts, cmdArgs))
initiateShutdown(err)
finishWg.Done()
runWg.Done()
}()
runWg.Wait()
err = getShutdownError()
if err != nil {
log.Printf("%s service experienced an error: %s\n", serviceName, err)
}
return err
}

View file

@ -0,0 +1,45 @@
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/safing/portmaster/service/updates/helper"
)
func init() {
rootCmd.AddCommand(showCmd)
// sub-commands of show are registered using registerComponent
}
var showCmd = &cobra.Command{
Use: "show",
PersistentPreRunE: func(*cobra.Command, []string) error {
// All show sub-commands need the registry but no logging.
return configureRegistry(false)
},
Short: "Show the command to run a Portmaster component yourself",
}
func show(opts *Options, cmdArgs []string) error {
// get original arguments
args := getExecArgs(opts, cmdArgs)
// adapt identifier
if onWindows {
opts.Identifier += exeSuffix
}
file, err := registry.GetFile(
helper.PlatformIdentifier(opts.Identifier),
)
if err != nil {
return fmt.Errorf("could not get component: %w", err)
}
fmt.Printf("%s %s\n", file.Path(), strings.Join(args, " "))
return nil
}

View file

@ -0,0 +1,49 @@
package main
import (
"sync"
)
var (
// startupComplete signals that the start procedure completed.
// The channel is not closed, just signaled once.
startupComplete = make(chan struct{})
// shuttingDown signals that we are shutting down.
// The channel will be closed, but may not be closed directly - only via initiateShutdown.
shuttingDown = make(chan struct{})
// shutdownError is protected by shutdownLock.
shutdownError error //nolint:unused,errname // Not what the linter thinks it is. Currently used on windows only.
shutdownLock sync.Mutex
)
func initiateShutdown(err error) {
shutdownLock.Lock()
defer shutdownLock.Unlock()
select {
case <-shuttingDown:
return
default:
shutdownError = err
close(shuttingDown)
}
}
func isShuttingDown() bool {
select {
case <-shuttingDown:
return true
default:
return false
}
}
//nolint:deadcode,unused // false positive on linux, currently used by windows only
func getShutdownError() error {
shutdownLock.Lock()
defer shutdownLock.Unlock()
return shutdownError
}

View file

@ -0,0 +1,158 @@
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/spf13/cobra"
portlog "github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/updater"
"github.com/safing/portmaster/service/updates/helper"
)
var (
reset bool
intelOnly bool
)
func init() {
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(purgeCmd)
flags := updateCmd.Flags()
flags.BoolVar(&reset, "reset", false, "Delete all resources and re-download the basic set")
flags.BoolVar(&intelOnly, "intel-only", false, "Only make downloading intel updates mandatory")
}
var (
updateCmd = &cobra.Command{
Use: "update",
Short: "Run a manual update process",
RunE: func(cmd *cobra.Command, args []string) error {
return downloadUpdates()
},
}
purgeCmd = &cobra.Command{
Use: "purge",
Short: "Remove old resource versions that are superseded by at least three versions",
RunE: func(cmd *cobra.Command, args []string) error {
return purge()
},
}
)
func indexRequired(cmd *cobra.Command) bool {
switch cmd {
case updateCmd, purgeCmd:
return true
default:
return false
}
}
func downloadUpdates() error {
// Check if only intel data is mandatory.
if intelOnly {
helper.IntelOnly()
}
// Set registry state notify callback.
registry.StateNotifyFunc = logProgress
// Set required updates.
registry.MandatoryUpdates = helper.MandatoryUpdates()
registry.AutoUnpack = helper.AutoUnpackUpdates()
if reset {
// Delete storage.
err := os.RemoveAll(registry.StorageDir().Path)
if err != nil {
return fmt.Errorf("failed to reset update dir: %w", err)
}
err = registry.StorageDir().Ensure()
if err != nil {
return fmt.Errorf("failed to create update dir: %w", err)
}
// Reset registry resources.
registry.ResetResources()
}
// Update all indexes.
err := registry.UpdateIndexes(context.TODO())
if err != nil {
return err
}
// Check if updates are available.
if len(registry.GetState().Updates.PendingDownload) == 0 {
log.Println("all resources are up to date")
return nil
}
// Download all required updates.
err = registry.DownloadUpdates(context.TODO(), true)
if err != nil {
return err
}
// Select versions and unpack the selected.
registry.SelectVersions()
err = registry.UnpackResources()
if err != nil {
return fmt.Errorf("failed to unpack resources: %w", err)
}
if !intelOnly {
// Fix chrome-sandbox permissions
if err := helper.EnsureChromeSandboxPermissions(registry); err != nil {
return fmt.Errorf("failed to fix electron permissions: %w", err)
}
}
return nil
}
func logProgress(state *updater.RegistryState) {
switch state.ID {
case updater.StateChecking:
if state.Updates.LastCheckAt == nil {
log.Println("checking for new versions")
}
case updater.StateDownloading:
if state.Details == nil {
log.Printf("downloading %d updates\n", len(state.Updates.PendingDownload))
} else if downloadDetails, ok := state.Details.(*updater.StateDownloadingDetails); ok {
if downloadDetails.FinishedUpTo < len(downloadDetails.Resources) {
log.Printf(
"[%d/%d] downloading %s",
downloadDetails.FinishedUpTo+1,
len(downloadDetails.Resources),
downloadDetails.Resources[downloadDetails.FinishedUpTo],
)
} else if state.Updates.LastDownloadAt == nil {
log.Println("finalizing downloads")
}
}
}
}
func purge() error {
portlog.SetLogLevel(portlog.TraceLevel)
// logging is configured as a persistent pre-run method inherited from
// the root command but since we don't use run.Run() we need to start
// logging ourself.
err := portlog.Start()
if err != nil {
fmt.Printf("failed to start logging: %s\n", err)
}
defer portlog.Shutdown()
registry.Purge(3)
return nil
}

View file

@ -0,0 +1,179 @@
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"log"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/safing/jess"
"github.com/safing/jess/filesig"
portlog "github.com/safing/portmaster/base/log"
"github.com/safing/portmaster/base/updater"
"github.com/safing/portmaster/service/updates/helper"
)
var (
verifyVerbose bool
verifyFix bool
verifyCmd = &cobra.Command{
Use: "verify",
Short: "Check integrity of updates / components",
RunE: func(cmd *cobra.Command, args []string) error {
return verifyUpdates(cmd.Context())
},
}
)
func init() {
rootCmd.AddCommand(verifyCmd)
flags := verifyCmd.Flags()
flags.BoolVarP(&verifyVerbose, "verbose", "v", false, "Enable verbose output")
flags.BoolVar(&verifyFix, "fix", false, "Delete and re-download broken components")
}
func verifyUpdates(ctx context.Context) error {
// Force registry to require signatures for all enabled scopes.
for _, opts := range registry.Verification {
if opts != nil {
opts.DownloadPolicy = updater.SignaturePolicyRequire
opts.DiskLoadPolicy = updater.SignaturePolicyRequire
}
}
// Load indexes again to ensure they are correctly signed.
err := registry.LoadIndexes(ctx)
if err != nil {
if verifyFix {
log.Println("[WARN] loading indexes failed, re-downloading...")
err = registry.UpdateIndexes(ctx)
if err != nil {
return fmt.Errorf("failed to download indexes: %w", err)
}
log.Println("[ OK ] indexes re-downloaded and verified")
} else {
return fmt.Errorf("failed to verify indexes: %w", err)
}
} else {
log.Println("[ OK ] indexes verified")
}
// Verify all resources.
export := registry.Export()
var verified, fails, skipped int
for _, rv := range export {
for _, version := range rv.Versions {
// Don't verify files we don't have.
if !version.Available {
continue
}
// Verify file signature.
file := version.GetFile()
fileData, err := file.Verify()
switch {
case err == nil:
verified++
if verifyVerbose {
verifOpts := registry.GetVerificationOptions(file.Identifier())
if verifOpts != nil {
log.Printf(
"[ OK ] valid signature for %s: signed by %s",
file.Path(), getSignedByMany(fileData, verifOpts.TrustStore),
)
} else {
log.Printf("[ OK ] valid signature for %s", file.Path())
}
}
case errors.Is(err, updater.ErrVerificationNotConfigured):
skipped++
if verifyVerbose {
log.Printf("[SKIP] no verification configured for %s", file.Path())
}
default:
log.Printf("[FAIL] failed to verify %s: %s", file.Path(), err)
fails++
if verifyFix {
// Delete file.
err = os.Remove(file.Path())
if err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Printf("[FAIL] failed to delete %s to prepare re-download: %s", file.Path(), err)
} else {
// We should not be changing the version, but we are in a cmd-like
// scenario here without goroutines.
version.Available = false
}
// Delete file sig.
err = os.Remove(file.Path() + filesig.Extension)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Printf("[FAIL] failed to delete %s to prepare re-download: %s", file.Path()+filesig.Extension, err)
} else {
// We should not be changing the version, but we are in a cmd-like
// scenario here without goroutines.
version.SigAvailable = false
}
}
}
}
}
if verified > 0 {
log.Printf("[STAT] verified %d files", verified)
}
if skipped > 0 && verifyVerbose {
log.Printf("[STAT] skipped %d files (no verification configured)", skipped)
}
if fails > 0 {
if verifyFix {
log.Printf("[WARN] verification failed on %d files, re-downloading...", fails)
} else {
return fmt.Errorf("failed to verify %d files", fails)
}
} else {
// Everything was verified!
return nil
}
// Start logging system for update process.
portlog.SetLogLevel(portlog.InfoLevel)
err = portlog.Start()
if err != nil {
log.Printf("[WARN] failed to start logging for monitoring update process: %s\n", err)
}
defer portlog.Shutdown()
// Re-download broken files.
registry.MandatoryUpdates = helper.MandatoryUpdates()
registry.AutoUnpack = helper.AutoUnpackUpdates()
err = registry.DownloadUpdates(ctx, true)
if err != nil {
return fmt.Errorf("failed to re-download files: %w", err)
}
return nil
}
func getSignedByMany(fds []*filesig.FileData, trustStore jess.TrustStore) string {
signedBy := make([]string, 0, len(fds))
for _, fd := range fds {
if sig := fd.Signature(); sig != nil {
for _, seal := range sig.Signatures {
if signet, err := trustStore.GetSignet(seal.ID, true); err == nil {
signedBy = append(signedBy, fmt.Sprintf("%s (%s)", signet.Info.Name, seal.ID))
} else {
signedBy = append(signedBy, seal.ID)
}
}
}
}
return strings.Join(signedBy, " and ")
}

View file

@ -0,0 +1,81 @@
package main
import (
"fmt"
"os"
"runtime"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/info"
)
var (
showShortVersion bool
showAllVersions bool
versionCmd = &cobra.Command{
Use: "version",
Short: "Display various portmaster versions",
Args: cobra.NoArgs,
PersistentPreRunE: func(*cobra.Command, []string) error {
if showAllVersions {
// If we are going to show all component versions,
// we need the registry to be configured.
if err := configureRegistry(false); err != nil {
return err
}
}
return nil
},
RunE: func(*cobra.Command, []string) error {
if !showAllVersions {
if showShortVersion {
fmt.Println(info.Version())
return nil
}
fmt.Println(info.FullVersion())
return nil
}
fmt.Printf("portmaster-start %s\n\n", info.Version())
fmt.Printf("Assets:\n")
all := registry.Export()
keys := make([]string, 0, len(all))
for identifier := range all {
keys = append(keys, identifier)
}
sort.Strings(keys)
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
for _, identifier := range keys {
res := all[identifier]
if showShortVersion {
// in "short" mode, skip all resources that are irrelevant on that platform
if !strings.HasPrefix(identifier, "all") && !strings.HasPrefix(identifier, runtime.GOOS) {
continue
}
}
fmt.Fprintf(tw, " %s\t%s\n", identifier, res.SelectedVersion.VersionNumber)
}
return tw.Flush()
},
}
)
func init() {
flags := versionCmd.Flags()
{
flags.BoolVar(&showShortVersion, "short", false, "Print only the version number.")
flags.BoolVar(&showAllVersions, "all", false, "Dump versions for all assets.")
}
rootCmd.AddCommand(versionCmd)
}

20
cmds/updatemgr/confirm.go Normal file
View file

@ -0,0 +1,20 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func confirm(msg string) bool {
fmt.Printf("%s: [y|n] ", msg)
scanner := bufio.NewScanner(os.Stdin)
ok := scanner.Scan()
if ok && strings.TrimSpace(scanner.Text()) == "y" {
return true
}
return false
}

View file

@ -3,18 +3,56 @@ package main
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/updater"
"github.com/safing/portmaster/base/utils"
)
var (
registry *updater.ResourceRegistry
distDir string
)
var rootCmd = &cobra.Command{
Use: "updatemgr",
Short: "Manage update artifacts.",
Short: "A simple tool to assist in the update and release process",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Check if the distribution directory exists.
absDistPath, err := filepath.Abs(distDir)
if err != nil {
return fmt.Errorf("failed to get absolute path of distribution directory: %w", err)
}
_, err = os.Stat(absDistPath)
if err != nil {
return fmt.Errorf("failed to access distribution directory: %w", err)
}
registry = &updater.ResourceRegistry{}
err = registry.Initialize(utils.NewDirStructure(absDistPath, 0o0755))
if err != nil {
return err
}
err = registry.ScanStorage("")
if err != nil {
return err
}
return nil
},
SilenceUsage: true,
}
func init() {
flags := rootCmd.PersistentFlags()
flags.StringVar(&distDir, "dist-dir", "dist", "Set the distribution directory. Falls back to ./dist if available.")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

33
cmds/updatemgr/purge.go Normal file
View file

@ -0,0 +1,33 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/log"
)
func init() {
rootCmd.AddCommand(purgeCmd)
}
var purgeCmd = &cobra.Command{
Use: "purge",
Short: "Remove old resource versions that are superseded by at least three versions",
RunE: purge,
}
func purge(cmd *cobra.Command, args []string) error {
log.SetLogLevel(log.TraceLevel)
err := log.Start()
if err != nil {
fmt.Printf("failed to start logging: %s\n", err)
}
defer log.Shutdown()
registry.SelectVersions()
registry.Purge(3)
return nil
}

195
cmds/updatemgr/release.go Normal file
View file

@ -0,0 +1,195 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/safing/portmaster/base/updater"
)
var (
releaseCmd = &cobra.Command{
Use: "release",
Short: "Release scans the distribution directory and creates registry indexes and the symlink structure",
Args: cobra.ExactArgs(1),
RunE: release,
}
preReleaseCmd = &cobra.Command{
Use: "prerelease",
Short: "Stage scans the specified directory and loads the indexes - it then creates a staging index with all files newer than the stable and beta indexes",
Args: cobra.ExactArgs(1),
RunE: release,
}
preReleaseFrom string
resetPreReleases bool
)
func init() {
rootCmd.AddCommand(releaseCmd)
rootCmd.AddCommand(preReleaseCmd)
preReleaseCmd.Flags().StringVar(&preReleaseFrom, "from", "", "Make a pre-release based on the given channel")
_ = preReleaseCmd.MarkFlagRequired("from")
preReleaseCmd.Flags().BoolVar(&resetPreReleases, "reset", false, "Reset pre-release assets")
}
func release(cmd *cobra.Command, args []string) error {
channel := args[0]
// Check if we want to reset instead.
if resetPreReleases {
return removeFilesFromIndex(getChannelVersions(preReleaseFrom, true))
}
// Write new index.
err := writeIndex(
channel,
getChannelVersions(preReleaseFrom, false),
)
if err != nil {
return err
}
// Only when doing a release:
if preReleaseFrom == "" {
// Create symlinks to latest stable versions.
if !confirm("\nDo you want to write latest symlinks?") {
fmt.Println("aborted...")
return nil
}
symlinksDir := registry.StorageDir().ChildDir("latest", 0o755)
err = registry.CreateSymlinks(symlinksDir)
if err != nil {
return err
}
fmt.Println("written latest symlinks")
}
return nil
}
func writeIndex(channel string, versions map[string]string) error {
// Create new index file.
indexFile := &updater.IndexFile{
Channel: channel,
Published: time.Now().UTC().Round(time.Second),
Releases: versions,
}
// Export versions and format them.
confirmData, err := json.MarshalIndent(indexFile, "", " ")
if err != nil {
return err
}
// Build index paths.
oldIndexPath := filepath.Join(registry.StorageDir().Path, channel+".json")
newIndexPath := filepath.Join(registry.StorageDir().Path, channel+".v2.json")
// Print preview.
fmt.Printf("%s\n%s\n%s\n\n", channel, oldIndexPath, newIndexPath)
fmt.Println(string(confirmData))
// Ask for confirmation.
if !confirm("\nDo you want to write this index?") {
fmt.Println("aborted...")
return nil
}
// Write indexes.
err = writeAsJSON(oldIndexPath, versions)
if err != nil {
return fmt.Errorf("failed to write %s: %w", oldIndexPath, err)
}
err = writeAsJSON(newIndexPath, indexFile)
if err != nil {
return fmt.Errorf("failed to write %s: %w", newIndexPath, err)
}
return nil
}
func writeAsJSON(path string, data any) error {
// Marshal to JSON.
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
// Write to disk.
err = os.WriteFile(path, jsonData, 0o0644) //nolint:gosec
if err != nil {
return err
}
fmt.Printf("written %s\n", path)
return nil
}
func removeFilesFromIndex(versions map[string]string) error {
// Print preview.
fmt.Println("To be deleted:")
for _, filePath := range versions {
fmt.Println(filePath)
}
// Ask for confirmation.
if !confirm("\nDo you want to delete these files?") {
fmt.Println("aborted...")
return nil
}
// Delete files.
for _, filePath := range versions {
err := os.Remove(filePath)
if err != nil {
return err
}
}
fmt.Println("deleted")
return nil
}
func getChannelVersions(prereleaseFrom string, storagePath bool) map[string]string {
if prereleaseFrom != "" {
registry.AddIndex(updater.Index{
Path: prereleaseFrom + ".json",
PreRelease: false,
})
err := registry.LoadIndexes(context.Background())
if err != nil {
panic(err)
}
}
// Sort all versions.
registry.SelectVersions()
export := registry.Export()
// Go through all versions and save the highest version, if not stable or beta.
versions := make(map[string]string)
for _, rv := range export {
highestVersion := rv.Versions[0]
// Ignore versions that are in the reference release channel.
if highestVersion.CurrentRelease {
continue
}
// Add highest version of matching release channel.
if storagePath {
versions[rv.Identifier] = rv.GetFile().Path()
} else {
versions[rv.Identifier] = highestVersion.VersionNumber
}
}
return versions
}

View file

@ -4,82 +4,46 @@ import (
"encoding/json"
"fmt"
"github.com/safing/portmaster/service/updates"
"github.com/spf13/cobra"
)
var (
scanConfig = updates.IndexScanConfig{
Name: "Portmaster Binaries",
PrimaryArtifact: "linux_amd64/portmaster-core",
BaseURL: "https://updates.safing.io/",
IgnoreFiles: []string{
// Indexes, checksums, latest symlinks.
"*.json",
"sha256*.txt",
"latest/**",
// Signatures.
"*.sig",
"**/*.sig",
// Related, but not required artifacts.
"**/*.apk",
"**/*install*",
"**/spn-hub*",
"**/jess*",
"**/hubs*.json",
"**/*mini*.mmdb.gz",
// Unsupported platforms.
"darwin_amd64/**",
"darwin_arm64/**",
// Deprecated artifacts.
"**/portmaster-start*",
"**/portmaster-app*",
"**/portmaster-notifier*",
"**/portmaster-wintoast*.dll",
"**/portmaster-snoretoast*.exe",
"**/portmaster-kext*.dll",
"**/profilemgr*.zip",
"**/settings*.zip",
"**/monitor*.zip",
"**/base*.zip",
"**/console*.zip",
},
UnpackFiles: map[string]string{
"gz": "**/*.gz",
"zip": "**/app2/**/portmaster-app*.zip",
},
}
scanCmd = &cobra.Command{
Use: "scan",
Short: "Scans the contents of the specified directory and creates an index from it.",
RunE: scan,
}
scanDir string
)
func init() {
rootCmd.AddCommand(scanCmd)
scanCmd.Flags().StringVarP(&scanDir, "dir", "d", "", "directory to create index from (required)")
_ = scanCmd.MarkFlagRequired("dir")
}
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Scan the specified directory and print the result",
RunE: scan,
}
func scan(cmd *cobra.Command, args []string) error {
index, err := updates.GenerateIndexFromDir(scanDir, scanConfig)
// Reset and rescan.
registry.ResetResources()
err := registry.ScanStorage("")
if err != nil {
return err
}
indexJson, err := json.MarshalIndent(&index, "", " ")
// Export latest versions.
data, err := json.MarshalIndent(exportSelected(true), "", " ")
if err != nil {
return fmt.Errorf("marshal index: %w", err)
return err
}
// Print them.
fmt.Println(string(data))
fmt.Printf("%s", indexJson)
return nil
}
func exportSelected(preReleases bool) map[string]string {
registry.SetUsePreReleases(preReleases)
registry.SelectVersions()
export := registry.Export()
versions := make(map[string]string)
for _, rv := range export {
versions[rv.Identifier] = rv.SelectedVersion.VersionNumber
}
return versions
}

View file

@ -3,7 +3,9 @@ package main
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
@ -11,35 +13,43 @@ import (
"github.com/safing/jess"
"github.com/safing/jess/filesig"
"github.com/safing/jess/truststores"
"github.com/safing/portmaster/service/updates"
)
func init() {
rootCmd.AddCommand(signCmd)
// Required argument: envelope
signCmd.Flags().StringVarP(&envelopeName, "envelope", "", "",
signCmd.PersistentFlags().StringVarP(&envelopeName, "envelope", "", "",
"specify envelope name used for signing",
)
_ = signCmd.MarkFlagRequired("envelope")
// Optional arguments: verbose, tsdir, tskeyring
signCmd.Flags().BoolVarP(&signVerbose, "verbose", "v", false,
signCmd.PersistentFlags().BoolVarP(&signVerbose, "verbose", "v", false,
"enable verbose output",
)
signCmd.Flags().StringVarP(&trustStoreDir, "tsdir", "", "",
signCmd.PersistentFlags().StringVarP(&trustStoreDir, "tsdir", "", "",
"specify a truststore directory (default loaded from JESS_TS_DIR env variable)",
)
signCmd.Flags().StringVarP(&trustStoreKeyring, "tskeyring", "", "",
signCmd.PersistentFlags().StringVarP(&trustStoreKeyring, "tskeyring", "", "",
"specify a truststore keyring namespace (default loaded from JESS_TS_KEYRING env variable) - lower priority than tsdir",
)
// Subcommand for signing indexes.
signCmd.AddCommand(signIndexCmd)
}
var (
signCmd = &cobra.Command{
Use: "sign [index.json file]",
Short: "Sign an index",
Use: "sign",
Short: "Sign resources",
RunE: sign,
Args: cobra.NoArgs,
}
signIndexCmd = &cobra.Command{
Use: "index",
Short: "Sign indexes",
RunE: signIndex,
Args: cobra.ExactArgs(1),
}
@ -48,8 +58,6 @@ var (
)
func sign(cmd *cobra.Command, args []string) error {
indexFilename := args[0]
// Setup trust store.
trustStore, err := setupTrustStore()
if err != nil {
@ -62,44 +70,159 @@ func sign(cmd *cobra.Command, args []string) error {
return err
}
// Read index file from disk.
unsignedIndexData, err := os.ReadFile(indexFilename)
if err != nil {
return fmt.Errorf("read index file: %w", err)
// Get all resources and iterate over all versions.
export := registry.Export()
var verified, signed, fails int
for _, rv := range export {
for _, version := range rv.Versions {
file := version.GetFile()
// Check if there is an existing signature.
_, err := os.Stat(file.Path() + filesig.Extension)
switch {
case err == nil || errors.Is(err, fs.ErrExist):
// If the file exists, just verify.
fileData, err := filesig.VerifyFile(
file.Path(),
file.Path()+filesig.Extension,
file.SigningMetadata(),
trustStore,
)
if err != nil {
fmt.Printf("[FAIL] signature error for %s: %s\n", file.Path(), err)
fails++
} else {
if signVerbose {
fmt.Printf("[ OK ] valid signature for %s: signed by %s\n", file.Path(), getSignedByMany(fileData, trustStore))
}
verified++
}
case errors.Is(err, fs.ErrNotExist):
// Attempt to sign file.
fileData, err := filesig.SignFile(
file.Path(),
file.Path()+filesig.Extension,
file.SigningMetadata(),
signingEnvelope,
trustStore,
)
if err != nil {
fmt.Printf("[FAIL] failed to sign %s: %s\n", file.Path(), err)
fails++
} else {
fmt.Printf("[SIGN] signed %s with %s\n", file.Path(), getSignedBySingle(fileData, trustStore))
signed++
}
default:
// File access error.
fmt.Printf("[FAIL] failed to access %s: %s\n", file.Path(), err)
fails++
}
}
}
// Parse index and check if it is valid.
index, err := updates.ParseIndex(unsignedIndexData, nil)
if err != nil {
return fmt.Errorf("invalid index: %w", err)
if verified > 0 {
fmt.Printf("[STAT] verified %d files\n", verified)
}
err = index.CanDoUpgrades()
if signed > 0 {
fmt.Printf("[STAT] signed %d files\n", signed)
}
if fails > 0 {
return fmt.Errorf("signing or verification failed on %d files", fails)
}
return nil
}
func signIndex(cmd *cobra.Command, args []string) error {
// Setup trust store.
trustStore, err := setupTrustStore()
if err != nil {
return fmt.Errorf("invalid index: %w", err)
return err
}
// Sign index.
signedIndexData, err := filesig.AddJSONSignature(unsignedIndexData, signingEnvelope, trustStore)
// Get envelope.
signingEnvelope, err := trustStore.GetEnvelope(envelopeName)
if err != nil {
return fmt.Errorf("sign: %w", err)
return err
}
// Check by parsing again.
index, err = updates.ParseIndex(signedIndexData, nil)
if err != nil {
return fmt.Errorf("invalid index after signing: %w", err)
}
err = index.CanDoUpgrades()
if err != nil {
return fmt.Errorf("invalid index after signing: %w", err)
// Resolve globs.
files := make([]string, 0, len(args))
for _, arg := range args {
matches, err := filepath.Glob(arg)
if err != nil {
return err
}
files = append(files, matches...)
}
// Write back to file.
err = os.WriteFile(indexFilename, signedIndexData, 0o0644)
if err != nil {
return fmt.Errorf("write signed index file: %w", err)
// Go through all files.
var verified, signed, fails int
for _, file := range files {
sigFile := file + filesig.Extension
// Ignore matches for the signatures.
if strings.HasSuffix(file, filesig.Extension) {
continue
}
// Check if there is an existing signature.
_, err := os.Stat(sigFile)
switch {
case err == nil || errors.Is(err, fs.ErrExist):
// If the file exists, just verify.
fileData, err := filesig.VerifyFile(
file,
sigFile,
nil,
trustStore,
)
if err == nil {
if signVerbose {
fmt.Printf("[ OK ] valid signature for %s: signed by %s\n", file, getSignedByMany(fileData, trustStore))
}
verified++
// Indexes are expected to change, so just sign the index again if verification fails.
continue
}
fallthrough
case errors.Is(err, fs.ErrNotExist):
// Attempt to sign file.
fileData, err := filesig.SignFile(
file,
sigFile,
nil,
signingEnvelope,
trustStore,
)
if err != nil {
fmt.Printf("[FAIL] failed to sign %s: %s\n", file, err)
fails++
} else {
fmt.Printf("[SIGN] signed %s with %s\n", file, getSignedBySingle(fileData, trustStore))
signed++
}
default:
// File access error.
fmt.Printf("[FAIL] failed to access %s: %s\n", sigFile, err)
fails++
}
}
if verified > 0 {
fmt.Printf("[STAT] verified %d files", verified)
}
if signed > 0 {
fmt.Printf("[STAT] signed %d files", signed)
}
if fails > 0 {
return fmt.Errorf("signing failed on %d files", fails)
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,33 +1,33 @@
[package]
name = "portmaster"
name = "app"
version = "0.1.0"
description = "Portmaster UI"
authors = ["Safing"]
license = ""
repository = ""
default-run = "portmaster"
default-run = "app"
edition = "2021"
rust-version = "1.64"
rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.1", features = [] }
tauri-build = { version = "2.0.0-rc.7", features = [] }
[dependencies]
# Tauri
tauri = { version = "2.0.1", features = ["tray-icon", "image-png", "config-json5", "devtools"] }
tauri-plugin-shell = "2.0.1"
tauri-plugin-dialog = "2.0.1"
tauri-plugin-clipboard-manager = "2.0.1"
tauri-plugin-os = "2.0.1"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-notification = "2.0.1"
tauri-plugin-log = "2.0.1"
tauri-plugin-window-state = "2.0.1"
tauri = { version = "2.0.0-rc.8", features = ["tray-icon", "image-png", "config-json5", "devtools"] }
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-dialog = "2.0.0-rc"
tauri-plugin-clipboard-manager = "2.0.0-rc"
tauri-plugin-os = "2.0.0-rc"
tauri-plugin-single-instance = "2.0.0-rc"
tauri-plugin-notification = "2.0.0-rc"
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-window-state = "2.0.0-rc"
tauri-cli = "2.0.1"
clap_lex = "0.7.2"
tauri-cli = "2.0.0-rc.8"
clap = { version = "4" }
# General
serde_json = "1.0"
@ -80,6 +80,3 @@ ctor = "0.2.6"
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]
[package.metadata.clippy]
allow = ["clippy::collapsible_else_if"]

View file

@ -1,41 +0,0 @@
# Update Tauri guide
Check latest versions of tauri packages and update them accordingly:
```toml
[build-dependencies]
tauri-build = { version = "2.0.0-beta.19", features = [] } # Update to latest
[dependencies]
# Tauri
tauri = { version = "2.0.0-beta.24", features = ["tray-icon", "image-png", "config-json5", "devtools"] } # Update to latest
tauri-plugin-shell = "2.0.0-beta"
tauri-plugin-dialog = "2.0.0-beta"
tauri-plugin-clipboard-manager = "2.0.0-beta"
tauri-plugin-os = "2.0.0-beta"
tauri-plugin-single-instance = "2.0.0-beta"
tauri-plugin-cli = "2.0.0-beta"
tauri-plugin-notification = "2.0.0-beta"
tauri-plugin-log = "2.0.0-beta"
tauri-plugin-window-state = "2.0.0-beta"
tauri-cli = "2.0.0-beta.21" # Update to latest
```
> The plugins will be auto updated based on tauri version.
Run:
```sh
cargo update
```
Update WIX installer template:
1. Get the latests [main.wxs](https://github.com/tauri-apps/tauri/blob/dev/tooling/bundler/src/bundle/windows/templates/main.wxs) template from the repository.
2. Replace the contents of `templates/main_original.wxs` with the repository version.
3. Replace the contents of `templates/main.wsx` and add the fallowing lines at the end of the file, inside the `Product` tag.
```xml
<!-- Service fragments -->
<CustomActionRef Id='InstallPortmasterService' />
<CustomActionRef Id='StopPortmasterService' />
<CustomActionRef Id='DeletePortmasterService' />
<!-- End Service fragments -->
```

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
use log::LevelFilter;
#[cfg(not(debug_assertions))]
const DEFAULT_LOG_LEVEL: log::LevelFilter = log::LevelFilter::Warn;
#[cfg(debug_assertions)]
const DEFAULT_LOG_LEVEL: log::LevelFilter = log::LevelFilter::Debug;
#[derive(Debug)]
pub struct CliArguments {
// Path to the installation directory
pub data: Option<String>,
// Log level to use: off, error, warn, info, debug, trace
pub log_level: log::LevelFilter,
// Start in the background without opening a window
pub background: bool,
// Enable experimental notifications via Tauri. Replaces the notifier app.
pub with_prompts: bool,
// Enable experimental prompt support via Tauri. Replaces the notifier app.
pub with_notifications: bool,
}
impl CliArguments {
fn parse_log(&mut self, level: String) {
self.log_level = match level.as_ref() {
"off" => LevelFilter::Off,
"error" => LevelFilter::Error,
"warn" => LevelFilter::Warn,
"info" => LevelFilter::Info,
"debug" => LevelFilter::Debug,
"trace" => LevelFilter::Trace,
_ => DEFAULT_LOG_LEVEL,
}
}
}
pub fn parse(raw: impl IntoIterator<Item = impl Into<std::ffi::OsString>>) -> CliArguments {
let mut cli = CliArguments {
data: None,
log_level: DEFAULT_LOG_LEVEL,
background: false,
with_prompts: false,
with_notifications: false,
};
let raw = clap_lex::RawArgs::new(raw);
let mut cursor = raw.cursor();
raw.next(&mut cursor); // Skip the bin
while let Some(arg) = raw.next(&mut cursor) {
if let Some((long, value)) = arg.to_long() {
match long {
Ok("data") => {
if let Some(value) = value {
cli.data = Some(value.to_string_lossy().into_owned());
}
}
Ok("log") => {
if let Some(value) = value {
cli.parse_log(value.to_string_lossy().into_owned());
}
}
Ok("background") => {
cli.background = true;
}
Ok("with_prompts") => {
cli.with_prompts = true;
}
Ok("with_notifications") => {
cli.with_notifications = true;
}
_ => {
// Ignore unexpected flags
}
}
} else if let Some(mut shorts) = arg.to_short() {
while let Some(short) = shorts.next() {
match short {
Ok('l') => {
if let Some(value) = shorts.next_value_os() {
let mut str = value.to_string_lossy().into_owned();
_ = str.remove(0); // remove first "=" from value (in -l=warn value will be "=warn")
cli.parse_log(str);
}
}
Ok('d') => {
if let Some(value) = shorts.next_value_os() {
let mut str = value.to_string_lossy().into_owned();
_ = str.remove(0); // remove first "=" from value (in -d=/data value will be "=/data")
cli.data = Some(str);
}
}
Ok('b') => cli.background = true,
_ => {
// Ignore unexpected flags
}
}
}
}
}
cli
}

View file

@ -16,7 +16,7 @@ pub struct Config {
pub theme: Theme,
}
const CONFIG_FILE_NAME: &str = "config.json";
const CONFIG_FILE_NAME: &'static str = "config.json";
pub fn save(app: &AppHandle, config: Config) -> tauri::Result<()> {
let config_dir = app.path().app_config_dir()?;

View file

@ -3,6 +3,7 @@
use std::{env, path::Path, time::Duration};
use clap::{Arg, Command};
use tauri::{AppHandle, Emitter, Listener, Manager, RunEvent, WindowEvent};
// Library crates
@ -13,13 +14,12 @@ mod service;
mod xdg;
// App modules
mod cli;
mod config;
mod portmaster;
mod traymenu;
mod window;
use log::{debug, error, info};
use log::{debug, error, info, LevelFilter};
use portmaster::PortmasterExt;
use tauri_plugin_log::RotationStrategy;
use traymenu::setup_tray_menu;
@ -30,6 +30,12 @@ extern crate lazy_static;
const FALLBACK_TO_OLD_UI_EXIT_CODE: i32 = 77;
#[cfg(not(debug_assertions))]
const LOG_LEVEL: LevelFilter = LevelFilter::Warn;
#[cfg(debug_assertions)]
const LOG_LEVEL: LevelFilter = LevelFilter::Debug;
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
@ -43,12 +49,29 @@ struct WsHandler {
is_first_connect: bool,
}
struct CliArguments {
// Path to the installation directory
data: Option<String>,
// Log level to use: off, error, warn, info, debug, trace
log: String,
// Start in the background without opening a window
background: bool,
// Enable experimental notifications via Tauri. Replaces the notifier app.
with_prompts: bool,
// Enable experimental prompt support via Tauri. Replaces the notifier app.
with_notifications: bool,
}
impl portmaster::Handler for WsHandler {
fn name(&self) -> String {
"main-handler".to_string()
}
fn on_connect(&mut self, cli: portapi::client::PortAPI) {
fn on_connect(&mut self, cli: portapi::client::PortAPI) -> () {
info!("connection established, creating main window");
// we successfully connected to Portmaster. Set is_first_connect to false
@ -116,18 +139,79 @@ fn show_webview_not_installed_dialog() -> i32 {
}
}
FALLBACK_TO_OLD_UI_EXIT_CODE
return FALLBACK_TO_OLD_UI_EXIT_CODE;
}
fn main() {
if tauri::webview_version().is_err() {
if let Err(_) = tauri::webview_version() {
std::process::exit(show_webview_not_installed_dialog());
}
let cli_args = cli::parse(std::env::args());
let matches = Command::new("Portmaster")
.ignore_errors(true)
.arg(
Arg::new("data")
.short('d')
.long("data")
.required(false)
.help("Path to the installation directory."),
)
.arg(
Arg::new("log")
.short('l')
.long("log")
.required(false)
.help("Log level to use: off, error, warn, info, debug, trace."),
)
.arg(
Arg::new("background")
.short('b')
.long("background")
.required(false)
.help("Start in the background without opening a window."),
)
.arg(
Arg::new("with_prompts")
.long("with_prompts")
.required(false)
.action(clap::ArgAction::SetTrue)
.help("Enable experimental notifications via Tauri. Replaces the notifier app."),
)
.arg(
Arg::new("with_notifications")
.long("with_notifications")
.required(false)
.action(clap::ArgAction::SetTrue)
.help("Enable experimental prompt support via Tauri. Replaces the notifier app."),
)
.get_matches();
let mut cli = CliArguments {
data: None,
log: LOG_LEVEL.to_string(),
background: false,
with_prompts: false,
with_notifications: false,
};
if let Some(data) = matches.get_one::<String>("data") {
cli.data = Some(data.to_string());
}
if let Some(log) = matches.get_one::<String>("log") {
cli.log = log.to_string();
}
if let Some(value) = matches.get_one::<bool>("with_prompts") {
cli.with_prompts = *value;
}
if let Some(value) = matches.get_one::<bool>("with_notifications") {
cli.with_notifications = *value;
}
#[cfg(target_os = "linux")]
let log_target = if let Some(data_dir) = cli_args.data {
let log_target = if let Some(data_dir) = cli.data {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Folder {
path: Path::new(&format!("{}/logs/app2", data_dir)).into(),
file_name: None,
@ -138,19 +222,30 @@ fn main() {
// TODO(vladimir): Permission for logs/app2 folder are not guaranteed. Use the default location for now.
#[cfg(target_os = "windows")]
let log_target = if let Some(data_dir) = cli_args.data {
let log_target = if let Some(data_dir) = cli.data {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None })
} else {
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout)
};
let mut log_level = LOG_LEVEL;
match cli.log.as_ref() {
"off" => log_level = LevelFilter::Off,
"error" => log_level = LevelFilter::Error,
"warn" => log_level = LevelFilter::Warn,
"info" => log_level = LevelFilter::Info,
"debug" => log_level = LevelFilter::Debug,
"trace" => log_level = LevelFilter::Trace,
_ => {}
}
let app = tauri::Builder::default()
// Shell plugin for open_external support
.plugin(tauri_plugin_shell::init())
// Initialize Logging plugin.
.plugin(
tauri_plugin_log::Builder::default()
.level(cli_args.log_level)
.level(log_level)
.rotation_strategy(RotationStrategy::KeepAll)
.clear_targets()
.target(log_target)
@ -192,18 +287,16 @@ fn main() {
});
// Handle cli flags:
app.portmaster().set_show_after_bootstrap(!cli.background);
app.portmaster()
.set_show_after_bootstrap(!cli_args.background);
app.portmaster()
.with_notification_support(cli_args.with_notifications);
app.portmaster()
.with_connection_prompts(cli_args.with_prompts);
.with_notification_support(cli.with_notifications);
app.portmaster().with_connection_prompts(cli.with_prompts);
// prepare a custom portmaster plugin handler that will show the splash-screen
// (if not in --background) and launch the tray-icon handler.
let handler = WsHandler {
handle: app.handle().clone(),
background: cli_args.background,
background: cli.background,
is_first_connect: true,
};
@ -216,8 +309,8 @@ fn main() {
.build(tauri::generate_context!())
.expect("error while running tauri application");
app.run(|handle, e| {
if let RunEvent::WindowEvent { label, event, .. } = e {
app.run(|handle, e| match e {
RunEvent::WindowEvent { label, event, .. } => {
if label != "main" {
// We only have one window at most so any other label is unexpected
return;
@ -231,22 +324,32 @@ fn main() {
//
// Note: the above javascript does NOT trigger the CloseRequested event so
// there's no need to handle that case here.
if let WindowEvent::CloseRequested { api, .. } = event {
debug!(
"window (label={}) close request received, forwarding to user-interface.",
label
);
//
match event {
WindowEvent::CloseRequested { api, .. } => {
debug!(
"window (label={}) close request received, forwarding to user-interface.",
label
);
api.prevent_close();
if let Some(window) = handle.get_webview_window(label.as_str()) {
let result = window.emit("exit-requested", "");
if let Err(err) = result {
error!("failed to emit event: {}", err.to_string());
api.prevent_close();
if let Some(window) = handle.get_webview_window(label.as_str()) {
let result = window.emit("exit-requested", "");
if let Err(err) = result {
error!("failed to emit event: {}", err.to_string());
}
} else {
error!("window was None");
}
} else {
error!("window was None");
}
_ => {}
}
}
// TODO(vladimir): why was this needed?
// RunEvent::ExitRequested { api, .. } => {
// api.prevent_exit();
// }
_ => {}
});
}

Some files were not shown because too many files have changed in this diff Show more