mirror of
https://github.com/safing/portmaster
synced 2025-04-13 23:49:11 +00:00
Revert "New updater/installer"
This commit is contained in:
parent
7e5d0b5d7d
commit
952577a431
190 changed files with 15286 additions and 9096 deletions
.github/workflows
Earthfilebase
api
config
database
dataroot
info
log
metrics
notifications
updater
cmds
hub
notifier
.gitignoreREADME.mdhttp_api.goicons.gomain.gonotification.gonotify.gonotify_linux.gonotify_windows.goshutdown.gosnoretoast-guid.patchspn.gosubsystems.gotray.go
wintoast
observation-hub
portmaster-core
portmaster-start
.gitignorebuildconsole_default.goconsole_windows.godirs.goinstall_windows.golock.gologs.gomain.gopackrecover_linux.gorun.goservice_windows.goshow.goshutdown.goupdate.goverify.goversion.go
updatemgr
desktop/tauri/src-tauri
70
.github/workflows/release.yml
vendored
70
.github/workflows/release.yml
vendored
|
@ -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
|
||||
|
19
.github/workflows/tauri.yml
vendored
19
.github/workflows/tauri.yml
vendored
|
@ -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
297
Earthfile
|
@ -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}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -56,6 +56,5 @@ func New(instance instance) (*Config, error) {
|
|||
}
|
||||
|
||||
type instance interface {
|
||||
DataDir() string
|
||||
SetCmdLineOperation(f func() error)
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ func TestMain(m *testing.M) {
|
|||
panic(err)
|
||||
}
|
||||
|
||||
err = Initialize(testDir)
|
||||
err = InitializeWithPath(testDir)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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
25
base/dataroot/root.go
Normal 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
|
||||
}
|
|
@ -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
13
base/log/flags.go
Normal 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")
|
||||
}
|
|
@ -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!
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -213,6 +213,4 @@ func New(instance instance) (*Metrics, error) {
|
|||
return module, nil
|
||||
}
|
||||
|
||||
type instance interface {
|
||||
DataDir() string
|
||||
}
|
||||
type instance interface{}
|
||||
|
|
|
@ -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
2
base/updater/doc.go
Normal 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
15
base/updater/export.go
Normal 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
347
base/updater/fetch.go
Normal 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
156
base/updater/file.go
Normal 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
57
base/updater/filename.go
Normal 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
|
||||
}
|
80
base/updater/filename_test.go
Normal file
80
base/updater/filename_test.go
Normal 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
91
base/updater/get.go
Normal 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
109
base/updater/indexes.go
Normal 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
|
||||
}
|
57
base/updater/indexes_test.go
Normal file
57
base/updater/indexes_test.go
Normal 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
33
base/updater/notifier.go
Normal 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 ¬ifier{
|
||||
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
270
base/updater/registry.go
Normal 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)
|
||||
}
|
35
base/updater/registry_test.go
Normal file
35
base/updater/registry_test.go
Normal 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
582
base/updater/resource.go
Normal 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
|
||||
}
|
119
base/updater/resource_test.go
Normal file
119
base/updater/resource_test.go
Normal 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
49
base/updater/signing.go
Normal 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
180
base/updater/state.go
Normal 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
272
base/updater/storage.go
Normal 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
|
||||
}
|
68
base/updater/storage_test.go
Normal file
68
base/updater/storage_test.go
Normal 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
195
base/updater/unpacking.go
Normal 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
359
base/updater/updating.go
Normal 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
|
||||
}
|
|
@ -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
34
cmds/notifier/.gitignore
vendored
Normal 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
5
cmds/notifier/README.md
Normal 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
63
cmds/notifier/http_api.go
Normal 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
25
cmds/notifier/icons.go
Normal 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
287
cmds/notifier/main.go
Normal 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
|
||||
}
|
35
cmds/notifier/notification.go
Normal file
35
cmds/notifier/notification.go
Normal 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
102
cmds/notifier/notify.go
Normal 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)
|
||||
}
|
160
cmds/notifier/notify_linux.go
Normal file
160
cmds/notifier/notify_linux.go
Normal 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)
|
||||
}
|
||||
}
|
184
cmds/notifier/notify_windows.go
Normal file
184
cmds/notifier/notify_windows.go
Normal 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
50
cmds/notifier/shutdown.go
Normal 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))
|
||||
}
|
||||
}
|
15
cmds/notifier/snoretoast-guid.patch
Normal file
15
cmds/notifier/snoretoast-guid.patch
Normal 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
104
cmds/notifier/spn.go
Normal 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
121
cmds/notifier/subsystems.go
Normal 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
217
cmds/notifier/tray.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
90
cmds/notifier/wintoast/notification_builder.go
Normal file
90
cmds/notifier/wintoast/notification_builder.go
Normal 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
|
||||
}
|
217
cmds/notifier/wintoast/wintoast.go
Normal file
217
cmds/notifier/wintoast/wintoast.go
Normal 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
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
6
cmds/portmaster-start/.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
# binaries
|
||||
portmaster-start
|
||||
portmaster-start.exe
|
||||
|
||||
# test dir
|
||||
test
|
77
cmds/portmaster-start/build
Executable file
77
cmds/portmaster-start/build
Executable 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}" "$@"
|
11
cmds/portmaster-start/console_default.go
Normal file
11
cmds/portmaster-start/console_default.go
Normal 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) {}
|
150
cmds/portmaster-start/console_windows.go
Normal file
150
cmds/portmaster-start/console_windows.go
Normal 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,
|
||||
}
|
||||
}
|
42
cmds/portmaster-start/dirs.go
Normal file
42
cmds/portmaster-start/dirs.go
Normal 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
|
||||
}
|
180
cmds/portmaster-start/install_windows.go
Normal file
180
cmds/portmaster-start/install_windows.go
Normal 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
|
||||
}
|
109
cmds/portmaster-start/lock.go
Normal file
109
cmds/portmaster-start/lock.go
Normal 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))
|
||||
}
|
127
cmds/portmaster-start/logs.go
Normal file
127
cmds/portmaster-start/logs.go
Normal 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
|
||||
}
|
||||
}
|
257
cmds/portmaster-start/main.go
Normal file
257
cmds/portmaster-start/main.go
Normal 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
123
cmds/portmaster-start/pack
Executable 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
|
82
cmds/portmaster-start/recover_linux.go
Normal file
82
cmds/portmaster-start/recover_linux.go
Normal 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"))
|
||||
}
|
486
cmds/portmaster-start/run.go
Normal file
486
cmds/portmaster-start/run.go
Normal 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)
|
||||
}
|
134
cmds/portmaster-start/service_windows.go
Normal file
134
cmds/portmaster-start/service_windows.go
Normal 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
|
||||
}
|
45
cmds/portmaster-start/show.go
Normal file
45
cmds/portmaster-start/show.go
Normal 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
|
||||
}
|
49
cmds/portmaster-start/shutdown.go
Normal file
49
cmds/portmaster-start/shutdown.go
Normal 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
|
||||
}
|
158
cmds/portmaster-start/update.go
Normal file
158
cmds/portmaster-start/update.go
Normal 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
|
||||
}
|
179
cmds/portmaster-start/verify.go
Normal file
179
cmds/portmaster-start/verify.go
Normal 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 ")
|
||||
}
|
81
cmds/portmaster-start/version.go
Normal file
81
cmds/portmaster-start/version.go
Normal 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
20
cmds/updatemgr/confirm.go
Normal 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
|
||||
}
|
|
@ -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
33
cmds/updatemgr/purge.go
Normal 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
195
cmds/updatemgr/release.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
2849
desktop/tauri/src-tauri/Cargo.lock
generated
2849
desktop/tauri/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"]
|
|
@ -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
|
@ -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
|
||||
}
|
|
@ -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()?;
|
||||
|
|
|
@ -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
Loading…
Add table
Reference in a new issue