diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss
new file mode 100644
index 00000000..5c958f4e
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss
@@ -0,0 +1,29 @@
+svg {
+ transform: scale(0.95);
+
+ path {
+ top: 0px;
+ left: 0px;
+ transform-origin: center center;
+ }
+
+ .shield-one {
+ transform: scale(.62);
+ }
+
+ .shield-two {
+ animation-delay: -1.2s;
+ opacity: .6;
+ transform: scale(.8);
+ }
+
+ .shield-three {
+ animation-delay: -2.5s;
+ opacity: .4;
+ transform: scale(1);
+ }
+
+ .shield-ok {
+ transform: scale(.62);
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts
new file mode 100644
index 00000000..3712f321
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts
@@ -0,0 +1,9 @@
+import { ChangeDetectionStrategy, Component } from "@angular/core";
+
+@Component({
+ selector: 'ext-header',
+ templateUrl: './header.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ styleUrls: ['./header.component.scss']
+})
+export class ExtHeaderComponent { }
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts
new file mode 100644
index 00000000..be62c26c
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts
@@ -0,0 +1 @@
+export * from './header.component';
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts
new file mode 100644
index 00000000..a33e1d04
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts
@@ -0,0 +1,45 @@
+import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { BehaviorSubject, filter, Observable, switchMap } from "rxjs";
+
+
+@Injectable()
+export class AuthIntercepter implements HttpInterceptor {
+ /** Used to delay requests until we loaded the access token from the extension storage. */
+ private loaded$ = new BehaviorSubject(false);
+
+ /** Holds the access token required to talk to the Portmaster API. */
+ private token: string | null = null;
+
+ constructor() {
+ // make sure we use the new access token once we get one.
+ chrome.storage.onChanged.addListener(changes => {
+ this.token = changes['key'].newValue || null;
+ })
+
+ // try to read the current access token from the extension storage.
+ chrome.storage.local.get('key', obj => {
+ this.token = obj.key || null;
+ console.log("got token", this.token)
+ this.loaded$.next(true);
+ })
+
+ chrome.runtime.sendMessage({ type: 'listRequests', tabId: 'current' }, (response: any) => {
+ console.log(response);
+ })
+ }
+
+ intercept(req: HttpRequest, next: HttpHandler): Observable> {
+ return this.loaded$.pipe(
+ filter(loaded => loaded),
+ switchMap(() => {
+ if (!!this.token) {
+ req = req.clone({
+ headers: req.headers.set("Authorization", "Bearer " + this.token)
+ })
+ }
+ return next.handle(req)
+ })
+ )
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts
new file mode 100644
index 00000000..159a5ea5
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts
@@ -0,0 +1,49 @@
+import { Injectable } from "@angular/core";
+import { Subject } from "rxjs";
+
+
+
+@Injectable({
+ providedIn: 'root'
+})
+export class RequestInterceptorService {
+ /** Used to emit when a new URL was requested */
+ private onUrlRequested$ = new Subject();
+
+ /** Used to emit when a URL has likely been blocked by the portmaster */
+ private onUrlBlocked$ = new Subject();
+
+ /** Emits when a new URL was requested */
+ get onUrlRequested() {
+ return this.onUrlRequested$.asObservable();
+ }
+
+ /** Emits when a new URL was likely blocked by the portmaster */
+ get onUrlBlocked() {
+ return this.onUrlBlocked$.asObservable();
+ }
+
+ constructor() {
+ this.registerCallbacks()
+ }
+
+ private registerCallbacks() {
+ const filter = {
+ urls: [
+ "http://*/*",
+ "https://*/*",
+ ]
+ };
+
+ chrome.webRequest.onBeforeRequest.addListener(details => this.onUrlRequested$.next(details), filter)
+ chrome.webRequest.onErrorOccurred.addListener(details => {
+ if (details.error !== "net::ERR_ADDRESS_UNREACHABLE") {
+ // we don't care about errors other than UNREACHABLE because that's error caused
+ // by the portmaster.
+ return;
+ }
+
+ this.onUrlBlocked$.next(details);
+ }, filter)
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts
new file mode 100644
index 00000000..a695cb02
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts
@@ -0,0 +1,2 @@
+export * from './welcome.module';
+
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html
new file mode 100644
index 00000000..017da699
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ Welcome to the
+
+ Portmaster Browser Extension
+
+
+
+
+
+
+
+ This extension adds direct support for Portmaster to your Browser. For that, it needs to get access to the
+ Portmaster on your system. For security reasons, you first need to authorize the Browser Extension to talk to the
+ Portmaster.
+
+
+
+
+
+
Waiting for Authorization
+
+ Please open the Portmaster and approve the authorization request.
+
+
+
+
+
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts
new file mode 100644
index 00000000..45d6b3d9
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts
@@ -0,0 +1,44 @@
+import { Component } from "@angular/core";
+import { Router } from "@angular/router";
+import { MetaAPI } from "@safing/portmaster-api";
+import { Subject, takeUntil } from "rxjs";
+
+@Component({
+ templateUrl: './intro.component.html',
+ styles: [
+ `
+ :host {
+ @apply flex flex-col h-full;
+ }
+ `
+ ]
+})
+export class IntroComponent {
+ private cancelRequest$ = new Subject();
+
+ state: 'authorizing' | 'failed' | '' = '';
+
+ constructor(
+ private meta: MetaAPI,
+ private router: Router,
+ ) { }
+
+ authorizeExtension() {
+ // cancel any pending request
+ this.cancelRequest$.next();
+
+ this.state = 'authorizing';
+ this.meta.requestApplicationAccess("Portmaster Browser Extension")
+ .pipe(takeUntil(this.cancelRequest$))
+ .subscribe({
+ next: token => {
+ chrome.storage.local.set(token);
+ console.log(token);
+ this.router.navigate(['/'])
+ },
+ error: err => {
+ this.state = 'failed';
+ }
+ })
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts
new file mode 100644
index 00000000..a0de7207
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts
@@ -0,0 +1,19 @@
+import { CommonModule } from "@angular/common";
+import { NgModule } from "@angular/core";
+import { OverlayStepperModule } from "@safing/ui";
+import { IntroComponent } from "./intro.component";
+
+@NgModule({
+ imports: [
+ CommonModule,
+ OverlayStepperModule,
+ ],
+ declarations: [
+ IntroComponent,
+ ],
+ exports: [
+ IntroComponent,
+ ]
+})
+export class WelcomeModule { }
+
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/assets/.gitkeep b/desktop/angular/projects/portmaster-chrome-extension/src/assets/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png b/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png
new file mode 100644
index 00000000..063948f1
Binary files /dev/null and b/desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png differ
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background.ts
new file mode 100644
index 00000000..e6a0986c
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/background.ts
@@ -0,0 +1,133 @@
+import { debounceTime, Subject } from "rxjs";
+import { CallRequest, ListRequests, NotifyRequests } from "./background/commands";
+import { Request, TabTracker } from "./background/tab-tracker";
+import { getCurrentTab } from "./background/tab-utils";
+
+export class BackgroundService {
+ /** a lookup map for tab trackers by tab-id */
+ private trackers = new Map();
+
+ /** used to signal the pop-up that new requests arrived */
+ private notifyRequests = new Subject();
+
+ constructor() {
+ // register a navigation-completed listener. This is fired when the user switches to a new website
+ // by entering it in the browser address bar.
+ chrome.webNavigation.onCompleted.addListener((details) => {
+ console.log("event: webNavigation.onCompleted", details);
+ })
+
+ // request event listeners for new requests and errors that occured for them.
+ // We only care about http and https here.
+ const filter = {
+ urls: [
+ 'http://*/*',
+ 'https://*/*'
+ ]
+ }
+ chrome.webRequest.onBeforeRequest.addListener(details => this.handleOnBeforeRequest(details), filter)
+ chrome.webRequest.onErrorOccurred.addListener(details => this.handleOnErrorOccured(details), filter)
+
+ // make sure we can communicate with the extension popup
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => this.handleMessage(msg, sender, sendResponse))
+
+ // set-up signalling of new requests to the pop-up
+ this.notifyRequests
+ .pipe(debounceTime(500))
+ .subscribe(async () => {
+ const currentTab = await getCurrentTab();
+ if (!!currentTab && !!currentTab.id) {
+ const msg: NotifyRequests = {
+ type: 'notifyRequests',
+ requests: this.mustGetTab({ tabId: currentTab.id }).allRequests()
+ }
+
+ chrome.runtime.sendMessage(msg)
+ }
+ })
+ }
+
+ /** Callback for messages sent by the popup */
+ private handleMessage(msg: CallRequest, sender: chrome.runtime.MessageSender, sendResponse: (msg: any) => void) {
+ console.log(`DEBUG: got message from ${sender.origin} (tab=${sender.tab?.id})`)
+
+ if (typeof msg !== 'object') {
+ console.error(`Received invalid message from popup`, msg)
+
+ return;
+ }
+
+ let response: Promise;
+ switch (msg.type) {
+ case 'listRequests':
+ response = this.handleListRequests(msg)
+ break;
+
+ default:
+ response = Promise.reject("unknown command")
+ }
+
+ response
+ .then(res => {
+ console.log(`DEBUG: sending response for command ${msg.type}`, res)
+ sendResponse(res);
+ })
+ .catch(err => {
+ console.error(`Failed to handle command ${msg.type}`, err)
+ sendResponse({
+ type: 'error',
+ details: err
+ });
+ })
+ }
+
+ /** Returns a list of all observed requests based on the filter in msg. */
+ private async handleListRequests(msg: ListRequests): Promise {
+ if (msg.tabId === 'current') {
+ const currentID = (await getCurrentTab()).id
+ if (!currentID) {
+ return [];
+ }
+
+ msg.tabId = currentID;
+ }
+
+ const tracker = this.mustGetTab({ tabId: msg.tabId as number })
+
+ if (!!msg.domain) {
+ return tracker.forDomain(msg.domain)
+ }
+
+ return tracker.allRequests()
+ }
+
+ /** Callback for chrome.webRequest.onBeforeRequest */
+ private handleOnBeforeRequest(details: chrome.webRequest.WebRequestDetails) {
+ this.mustGetTab(details).trackRequest(details)
+
+ this.notifyRequests.next();
+ }
+
+ /** Callback for chrome.webRequest.onErrorOccured */
+ private handleOnErrorOccured(details: chrome.webRequest.WebResponseErrorDetails) {
+ this.mustGetTab(details).trackError(details);
+
+ this.notifyRequests.next();
+ }
+
+ /** Returns the tab-tracker for tabId. Creates a new tracker if none exists. */
+ private mustGetTab({ tabId }: { tabId: number }): TabTracker {
+ let tracker = this.trackers.get(tabId);
+ if (!tracker) {
+ tracker = new TabTracker(tabId)
+ this.trackers.set(tabId, tracker)
+ }
+
+ return tracker;
+ }
+}
+
+/** start the background service once we got successfully installed. */
+chrome.runtime.onInstalled.addListener(() => {
+ new BackgroundService()
+});
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts
new file mode 100644
index 00000000..6bfdcd88
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts
@@ -0,0 +1,14 @@
+import { Request } from "./tab-tracker";
+
+export interface ListRequests {
+ type: 'listRequests';
+ domain?: string;
+ tabId: number | 'current';
+}
+
+export interface NotifyRequests {
+ type: 'notifyRequests',
+ requests: Request[];
+}
+
+export type CallRequest = ListRequests;
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts
new file mode 100644
index 00000000..f5a0628e
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts
@@ -0,0 +1,126 @@
+import { deepClone } from "@safing/portmaster-api";
+
+export interface Request {
+ /** The ID assigned by the browser */
+ id: string;
+
+ /** The domain this request was for */
+ domain: string;
+
+ /** The timestamp in milliseconds since epoch at which the request was initiated */
+ time: number;
+
+ /** Whether or not this request errored with net::ERR_ADDRESS_UNREACHABLE */
+ isUnreachable: boolean;
+}
+
+/**
+ * TabTracker tracks requests to domains made by a single browser tab.
+ */
+export class TabTracker {
+ /** A list of requests observed for this tab order by time they have been initiated */
+ private requests: Request[] = [];
+
+ /** A lookup map for requests to specific domains */
+ private byDomain = new Map();
+
+ /** A lookup map for requests by the chrome request ID */
+ private byRequestId = new Map;
+
+ constructor(public readonly tabId: number) { }
+
+ /** Returns an array of all requests observed in this tab. */
+ allRequests(): Request[] {
+ return deepClone(this.requests)
+ }
+
+ /** Returns a list of requests that have been observed for domain */
+ forDomain(domain: string): Request[] {
+ if (!domain.endsWith(".")) {
+ domain += "."
+ }
+
+ return this.byDomain.get(domain) || [];
+ }
+
+ /** Call to add the details of a web-request to this tab-tracker */
+ trackRequest(details: chrome.webRequest.WebRequestDetails) {
+ // If this is the wrong tab ID ignore the request details
+ if (details.tabId !== this.tabId) {
+ console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${details.tabId}`)
+
+ return;
+ }
+
+ // if the type of the request is for the main_frame the user switched to a new website.
+ // In that case, we can wipe out all currently stored requests as the user will likely not
+ // care anymore.
+ if (details.type === "main_frame") {
+ this.clearState();
+ }
+
+ // get the domain of the request normalized to contain the trailing dot.
+ let domain = new URL(details.url).host;
+ if (!domain.endsWith(".")) {
+ domain += "."
+ }
+
+ const req: Request = {
+ id: details.requestId,
+ domain: domain,
+ time: details.timeStamp,
+ isUnreachable: false, // we don't actually know that yet
+ }
+
+ this.requests.push(req);
+ this.byRequestId.set(req.id, req)
+
+ // Add the request to the by-domain lookup map
+ let byDomainRequests = this.byDomain.get(req.domain);
+ if (!byDomainRequests) {
+ byDomainRequests = [];
+ this.byDomain.set(req.domain, byDomainRequests)
+ }
+ byDomainRequests.push(req)
+
+ console.log(`DEBUG: observed request ${req.id} to ${req.domain}`)
+ }
+
+ /** Call to notify the tab-tracker of a request error */
+ trackError(errorDetails: chrome.webRequest.WebResponseErrorDetails) {
+ // we only care about net::ERR_ADDRESS_UNREACHABLE here because that's how the
+ // Portmaster blocks the request.
+
+ // TODO(ppacher): docs say we must not rely on that value so we should figure out a better
+ // way to detect if the error is caused by the Portmaster.
+ if (errorDetails.error !== "net::ERR_ADDRESS_UNREACHABLE") {
+ return;
+ }
+
+ // the the previsouly observed request by the request ID.
+ const req = this.byRequestId.get(errorDetails.requestId)
+ if (!req) {
+ console.error("TabTracker.trackError: request has not been observed before")
+
+ return
+ }
+
+ // make sure the error details actually happend for the observed tab.
+ if (errorDetails.tabId !== this.tabId) {
+ console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${errorDetails.tabId}`)
+
+ return;
+ }
+
+ // mark the request as unreachable.
+ req.isUnreachable = true;
+ console.log(`DEBUG: marked request ${req.id} to ${req.domain} as unreachable`)
+ }
+
+ /** Clears the current state of the tab tracker */
+ private clearState() {
+ this.requests = [];
+ this.byDomain = new Map();
+ this.byRequestId = new Map();
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts
new file mode 100644
index 00000000..36635ca8
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts
@@ -0,0 +1,9 @@
+
+/** Queries and returns the currently active tab */
+export function getCurrentTab(): Promise {
+ return new Promise((resolve) => {
+ chrome.tabs.query({ active: true, lastFocusedWindow: true }, ([tab]) => {
+ resolve(tab);
+ })
+ })
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts
new file mode 100644
index 00000000..ffe8aed7
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts
@@ -0,0 +1,3 @@
+export const environment = {
+ production: false
+};
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts
new file mode 100644
index 00000000..f56ff470
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts
@@ -0,0 +1,16 @@
+// This file can be replaced during build by using the `fileReplacements` array.
+// `ng build` replaces `environment.ts` with `environment.prod.ts`.
+// The list of file replacements can be found in `angular.json`.
+
+export const environment = {
+ production: false
+};
+
+/*
+ * For easier debugging in development mode, you can import the following file
+ * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
+ *
+ * This import should be commented out in production mode because it will have a negative impact
+ * on performance if an error is thrown.
+ */
+// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico b/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico
new file mode 100644
index 00000000..997406ad
Binary files /dev/null and b/desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico differ
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/index.html b/desktop/angular/projects/portmaster-chrome-extension/src/index.html
new file mode 100644
index 00000000..afb08c65
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ PortmasterChromeExtension
+
+
+
+
+
+
+
+
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/main.ts b/desktop/angular/projects/portmaster-chrome-extension/src/main.ts
new file mode 100644
index 00000000..c7b673cf
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/main.ts
@@ -0,0 +1,12 @@
+import { enableProdMode } from '@angular/core';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+
+import { AppModule } from './app/app.module';
+import { environment } from './environments/environment';
+
+if (environment.production) {
+ enableProdMode();
+}
+
+platformBrowserDynamic().bootstrapModule(AppModule)
+ .catch(err => console.error(err));
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json b/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json
new file mode 100644
index 00000000..db045a05
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/manifest.json
@@ -0,0 +1,23 @@
+{
+ "name": "Portmaster Browser Extension",
+ "version": "0.1",
+ "description": "Browser Extension for even better Portmaster integration",
+ "manifest_version": 2,
+ "permissions": [
+ "activeTab",
+ "storage",
+ "webRequest",
+ "webNavigation",
+ "*://*/*"
+ ],
+ "browser_action": {
+ "default_popup": "index.html",
+ "default_icon": {
+ "128": "assets/icon_128.png"
+ }
+ },
+ "background": {
+ "scripts": ["background.js"],
+ "persistent": true
+ }
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts b/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts
new file mode 100644
index 00000000..429bb9ef
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts
@@ -0,0 +1,53 @@
+/**
+ * This file includes polyfills needed by Angular and is loaded before the app.
+ * You can add your own extra polyfills to this file.
+ *
+ * This file is divided into 2 sections:
+ * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
+ * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
+ * file.
+ *
+ * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
+ * automatically update themselves. This includes recent versions of Safari, Chrome (including
+ * Opera), Edge on the desktop, and iOS and Chrome on mobile.
+ *
+ * Learn more in https://angular.io/guide/browser-support
+ */
+
+/***************************************************************************************************
+ * BROWSER POLYFILLS
+ */
+
+/**
+ * By default, zone.js will patch all possible macroTask and DomEvents
+ * user can disable parts of macroTask/DomEvents patch by setting following flags
+ * because those flags need to be set before `zone.js` being loaded, and webpack
+ * will put import in the top of bundle, so user need to create a separate file
+ * in this directory (for example: zone-flags.ts), and put the following flags
+ * into that file, and then add the following code before importing zone.js.
+ * import './zone-flags';
+ *
+ * The flags allowed in zone-flags.ts are listed here.
+ *
+ * The following flags will work for all browsers.
+ *
+ * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
+ * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
+ * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
+ *
+ * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
+ * with the following flag, it will bypass `zone.js` patch for IE/Edge
+ *
+ * (window as any).__Zone_enable_cross_context_check = true;
+ *
+ */
+
+/***************************************************************************************************
+ * Zone JS is required by default for Angular itself.
+ */
+import 'zone.js'; // Included with Angular CLI.
+
+
+/***************************************************************************************************
+ * APPLICATION IMPORTS
+ */
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss b/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss
new file mode 100644
index 00000000..e41283cd
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/styles.scss
@@ -0,0 +1,8 @@
+/* You can add global styles to this file, and also import other style files */
+
+@import 'tailwindcss/base';
+@import 'tailwindcss/components';
+@import 'tailwindcss/utilities';
+
+
+@import '@angular/cdk/overlay-prebuilt';
diff --git a/desktop/angular/projects/portmaster-chrome-extension/src/test.ts b/desktop/angular/projects/portmaster-chrome-extension/src/test.ts
new file mode 100644
index 00000000..51bb0206
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/src/test.ts
@@ -0,0 +1,14 @@
+// This file is required by karma.conf.js and loads recursively all the .spec and framework files
+
+import 'zone.js/testing';
+import { getTestBed } from '@angular/core/testing';
+import {
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting
+} from '@angular/platform-browser-dynamic/testing';
+
+// First, initialize the Angular testing environment.
+getTestBed().initTestEnvironment(
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting(),
+);
diff --git a/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json
new file mode 100644
index 00000000..28c28154
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json
@@ -0,0 +1,18 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/app",
+ "types": [
+ "chrome"
+ ]
+ },
+ "files": [
+ "src/main.ts",
+ "src/polyfills.ts",
+ "src/background.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json
new file mode 100644
index 00000000..b66a2f0b
--- /dev/null
+++ b/desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json
@@ -0,0 +1,18 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/spec",
+ "types": [
+ "jasmine"
+ ]
+ },
+ "files": [
+ "src/test.ts",
+ "src/polyfills.ts"
+ ],
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/README.md b/desktop/angular/projects/safing/portmaster-api/README.md
new file mode 100644
index 00000000..fc4110d2
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/README.md
@@ -0,0 +1,24 @@
+# PortmasterApi
+
+This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.0.
+
+## Code scaffolding
+
+Run `ng generate component component-name --project portmaster-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project portmaster-api`.
+> Note: Don't forget to add `--project portmaster-api` or else it will be added to the default project in your `angular.json` file.
+
+## Build
+
+Run `ng build portmaster-api` to build the project. The build artifacts will be stored in the `dist/` directory.
+
+## Publishing
+
+After building your library with `ng build portmaster-api`, go to the dist folder `cd dist/portmaster-api` and run `npm publish`.
+
+## Running unit tests
+
+Run `ng test portmaster-api` to execute the unit tests via [Karma](https://karma-runner.github.io).
+
+## Further help
+
+To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
diff --git a/desktop/angular/projects/safing/portmaster-api/karma.conf.js b/desktop/angular/projects/safing/portmaster-api/karma.conf.js
new file mode 100644
index 00000000..6f9bd935
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/karma.conf.js
@@ -0,0 +1,44 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+module.exports = function (config) {
+ config.set({
+ basePath: '',
+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
+ plugins: [
+ require('karma-jasmine'),
+ require('karma-chrome-launcher'),
+ require('karma-jasmine-html-reporter'),
+ require('karma-coverage'),
+ require('@angular-devkit/build-angular/plugins/karma')
+ ],
+ client: {
+ jasmine: {
+ // you can add configuration options for Jasmine here
+ // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
+ // for example, you can disable the random execution with `random: false`
+ // or set a specific seed with `seed: 4321`
+ },
+ clearContext: false // leave Jasmine Spec Runner output visible in browser
+ },
+ jasmineHtmlReporter: {
+ suppressAll: true // removes the duplicated traces
+ },
+ coverageReporter: {
+ dir: require('path').join(__dirname, '../../../coverage/safing/portmaster-api'),
+ subdir: '.',
+ reporters: [
+ { type: 'html' },
+ { type: 'text-summary' }
+ ]
+ },
+ reporters: ['progress', 'kjhtml'],
+ port: 9876,
+ colors: true,
+ logLevel: config.LOG_INFO,
+ autoWatch: true,
+ browsers: ['Chrome'],
+ singleRun: false,
+ restartOnFileChange: true
+ });
+};
diff --git a/desktop/angular/projects/safing/portmaster-api/ng-package.json b/desktop/angular/projects/safing/portmaster-api/ng-package.json
new file mode 100644
index 00000000..4ea94f9a
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/ng-package.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../../dist-lib/safing/portmaster-api",
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/package-lock.json b/desktop/angular/projects/safing/portmaster-api/package-lock.json
new file mode 100644
index 00000000..848065cc
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/package-lock.json
@@ -0,0 +1,132 @@
+{
+ "name": "@safing/portmaster-api",
+ "version": "0.0.1",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@safing/portmaster-api",
+ "version": "0.0.1",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@types/jasmine": "^4.0.3"
+ },
+ "peerDependencies": {
+ "@angular/common": "^14.0.0",
+ "@angular/core": "^14.0.0"
+ }
+ },
+ "node_modules/@angular/common": {
+ "version": "14.0.5",
+ "resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
+ "integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || >=16.10.0"
+ },
+ "peerDependencies": {
+ "@angular/core": "14.0.5",
+ "rxjs": "^6.5.3 || ^7.4.0"
+ }
+ },
+ "node_modules/@angular/core": {
+ "version": "14.0.5",
+ "resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
+ "integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || >=16.10.0"
+ },
+ "peerDependencies": {
+ "rxjs": "^6.5.3 || ^7.4.0",
+ "zone.js": "~0.11.4"
+ }
+ },
+ "node_modules/@types/jasmine": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
+ "integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
+ "dev": true
+ },
+ "node_modules/rxjs": {
+ "version": "7.5.6",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
+ "integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ },
+ "node_modules/zone.js": {
+ "version": "0.11.6",
+ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
+ "integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.3.0"
+ }
+ }
+ },
+ "dependencies": {
+ "@angular/common": {
+ "version": "14.0.5",
+ "resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
+ "integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
+ "peer": true,
+ "requires": {
+ "tslib": "^2.3.0"
+ }
+ },
+ "@angular/core": {
+ "version": "14.0.5",
+ "resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
+ "integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
+ "peer": true,
+ "requires": {
+ "tslib": "^2.3.0"
+ }
+ },
+ "@types/jasmine": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
+ "integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
+ "dev": true
+ },
+ "rxjs": {
+ "version": "7.5.6",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
+ "integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
+ "peer": true,
+ "requires": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ },
+ "zone.js": {
+ "version": "0.11.6",
+ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
+ "integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
+ "peer": true,
+ "requires": {
+ "tslib": "^2.3.0"
+ }
+ }
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/package.json b/desktop/angular/projects/safing/portmaster-api/package.json
new file mode 100644
index 00000000..98483319
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@safing/portmaster-api",
+ "version": "0.0.1",
+ "peerDependencies": {
+ "@angular/common": "^14.0.0",
+ "@angular/core": "^14.0.0"
+ },
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "devDependencies": {
+ "@types/jasmine": "^4.0.3"
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts
new file mode 100644
index 00000000..814b67ff
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts
@@ -0,0 +1,262 @@
+import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
+import { Inject, Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators';
+import {
+ AppProfile,
+ FlatConfigObject,
+ LayeredProfile,
+ TagDescription,
+ flattenProfileConfig,
+} from './app-profile.types';
+import {
+ PORTMASTER_HTTP_API_ENDPOINT,
+ PortapiService,
+} from './portapi.service';
+import { Process } from './portapi.types';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AppProfileService {
+ private watchedProfiles = new Map>();
+
+ constructor(
+ private portapi: PortapiService,
+ private http: HttpClient,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string
+ ) { }
+
+ /**
+ * Returns the database key of a profile.
+ *
+ * @param source The source of the profile.
+ * @param id The profile ID.
+ */
+ getKey(source: string, id: string): string;
+
+ /**
+ * Returns the database key of a profile
+ *
+ * @param p The app-profile itself..
+ */
+ getKey(p: AppProfile): string;
+
+ getKey(idOrSourceOrProfile: string | AppProfile, id?: string): string {
+ if (typeof idOrSourceOrProfile === 'object') {
+ return this.getKey(idOrSourceOrProfile.Source, idOrSourceOrProfile.ID);
+ }
+
+ let key = idOrSourceOrProfile;
+
+ if (!!id) {
+ key = `core:profiles/${idOrSourceOrProfile}/${id}`;
+ }
+
+ return key;
+ }
+
+ /**
+ * Load an application profile.
+ *
+ * @param sourceAndId The full profile ID including source
+ */
+ getAppProfile(sourceAndId: string): Observable;
+
+ /**
+ * Load an application profile.
+ *
+ * @param source The source of the profile
+ * @param id The ID of the profile
+ */
+ getAppProfile(source: string, id: string): Observable;
+
+ getAppProfile(
+ sourceOrSourceAndID: string,
+ id?: string
+ ): Observable {
+ let source = sourceOrSourceAndID;
+ if (id !== undefined) {
+ source += '/' + id;
+ }
+ const key = `core:profiles/${source}`;
+
+ if (this.watchedProfiles.has(key)) {
+ return this.watchedProfiles.get(key)!.pipe(take(1));
+ }
+
+ return this.getAppProfileFromKey(key);
+ }
+
+ setProfileIcon(
+ content: string | ArrayBuffer,
+ mimeType: string
+ ): Observable<{ filename: string }> {
+ return this.http.post<{ filename: string }>(
+ `${this.httpAPI}/v1/profile/icon`,
+ content,
+ {
+ headers: new HttpHeaders({
+ 'Content-Type': mimeType,
+ }),
+ }
+ );
+ }
+
+ /**
+ * Loads an application profile by it's database key.
+ *
+ * @param key The key of the application profile.
+ */
+ getAppProfileFromKey(key: string): Observable {
+ return this.portapi.get(key);
+ }
+
+ /**
+ * Loads the global-configuration profile.
+ */
+ globalConfig(): Observable {
+ return this.getAppProfile('special', 'global-config').pipe(
+ map((profile) => flattenProfileConfig(profile.Config))
+ );
+ }
+
+ /** Returns all possible process tags. */
+ tagDescriptions(): Observable {
+ return this.http
+ .get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`)
+ .pipe(map((result) => result.Tags));
+ }
+
+ /**
+ * Watches an application profile for changes.
+ *
+ * @param source The source of the profile
+ * @param id The ID of the profile
+ */
+ watchAppProfile(sourceAndId: string): Observable;
+ /**
+ * Watches an application profile for changes.
+ *
+ * @param source The source of the profile
+ * @param id The ID of the profile
+ */
+ watchAppProfile(source: string, id: string): Observable;
+
+ watchAppProfile(sourceAndId: string, id?: string): Observable {
+ let key = '';
+
+ if (id === undefined) {
+ key = sourceAndId;
+ if (!key.startsWith('core:profiles/')) {
+ key = `core:profiles/${key}`;
+ }
+ } else {
+ key = `core:profiles/${sourceAndId}/${id}`;
+ }
+
+ if (this.watchedProfiles.has(key)) {
+ return this.watchedProfiles.get(key)!;
+ }
+
+ const stream = this.portapi.get(key).pipe(
+ mergeMap(() => this.portapi.watch(key)),
+ finalize(() => {
+ console.log(
+ 'watchAppProfile: removing cached profile stream for ' + key
+ );
+ this.watchedProfiles.delete(key);
+ }),
+ share({
+ connector: () => new BehaviorSubject(null),
+ resetOnRefCountZero: true,
+ }),
+ filter((profile) => profile !== null)
+ ) as Observable;
+
+ this.watchedProfiles.set(key, stream);
+
+ return stream;
+ }
+
+ /** @deprecated use saveProfile instead */
+ saveLocalProfile(profile: AppProfile): Observable {
+ return this.saveProfile(profile);
+ }
+
+ /**
+ * Save an application profile.
+ *
+ * @param profile The profile to save
+ */
+ saveProfile(profile: AppProfile): Observable {
+ profile.LastEdited = Math.floor(new Date().getTime() / 1000);
+ return this.portapi.update(
+ `core:profiles/${profile.Source}/${profile.ID}`,
+ profile
+ );
+ }
+
+ /**
+ * Watch all application profiles
+ */
+ watchProfiles(): Observable {
+ return this.portapi.watchAll('core:profiles/');
+ }
+
+ watchLayeredProfile(source: string, id: string): Observable;
+
+ /**
+ * Watches the layered runtime profile for a given application
+ * profile.
+ *
+ * @param profile The app profile
+ */
+ watchLayeredProfile(profile: AppProfile): Observable;
+
+ watchLayeredProfile(
+ profileOrSource: string | AppProfile,
+ id?: string
+ ): Observable {
+ if (typeof profileOrSource == 'object') {
+ id = profileOrSource.ID;
+ profileOrSource = profileOrSource.Source;
+ }
+
+ const key = `runtime:layeredProfile/${profileOrSource}/${id}`;
+ return this.portapi.watch(key);
+ }
+
+ /**
+ * Loads the layered runtime profile for a given application
+ * profile.
+ *
+ * @param profile The app profile
+ */
+ getLayeredProfile(profile: AppProfile): Observable {
+ const key = `runtime:layeredProfile/${profile.Source}/${profile.ID}`;
+ return this.portapi.get(key);
+ }
+
+ /**
+ * Delete an application profile.
+ *
+ * @param profile The profile to delete
+ */
+ deleteProfile(profile: AppProfile): Observable {
+ return this.portapi.delete(`core:profiles/${profile.Source}/${profile.ID}`);
+ }
+
+ getProcessesByProfile(profileOrId: AppProfile | string): Observable {
+ if (typeof profileOrId === 'object') {
+ profileOrId = profileOrId.Source + "/" + profileOrId.ID
+ }
+
+ return this.http.get(`${this.httpAPI}/v1/process/list/by-profile/${profileOrId}`)
+ }
+
+ getProcessByPid(pid: number): Observable {
+ return this.http.get(`${this.httpAPI}/v1/process/group-leader/${pid}`)
+ }
+}
+
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts
new file mode 100644
index 00000000..986d62ff
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts
@@ -0,0 +1,215 @@
+import { BaseSetting, OptionValueType, SettingValueType } from './config.types';
+import { SecurityLevel } from './core.types';
+import { Record } from './portapi.types';
+
+export interface ConfigMap {
+ [key: string]: ConfigObject;
+}
+
+export type ConfigObject = OptionValueType | ConfigMap;
+
+export interface FlatConfigObject {
+ [key: string]: OptionValueType;
+}
+
+
+export interface LayeredProfile extends Record {
+ // LayerIDs is a list of all profiles that are used
+ // by this layered profile. Profiles are evaluated in
+ // order.
+ LayerIDs: string[];
+
+ // The current revision counter of the layered profile.
+ RevisionCounter: number;
+}
+
+export enum FingerprintType {
+ Tag = 'tag',
+ Cmdline = 'cmdline',
+ Env = 'env',
+ Path = 'path',
+}
+
+export enum FingerpringOperation {
+ Equal = 'equals',
+ Prefix = 'prefix',
+ Regex = 'regex',
+}
+
+export interface Fingerprint {
+ Type: FingerprintType;
+ Key: string;
+ Operation: FingerpringOperation;
+ Value: string;
+}
+
+export interface TagDescription {
+ ID: string;
+ Name: string;
+ Description: string;
+}
+
+export interface Icon {
+ Type: 'database' | 'path' | 'api';
+ Source: '' | 'user' | 'import' | 'core' | 'ui';
+ Value: string;
+}
+
+export interface AppProfile extends Record {
+ ID: string;
+ LinkedPath: string; // deprecated
+ PresentationPath: string;
+ Fingerprints: Fingerprint[];
+ Created: number;
+ LastEdited: number;
+ Config?: ConfigMap;
+ Description: string;
+ Warning: string;
+ WarningLastUpdated: string;
+ Homepage: string;
+ Icons: Icon[];
+ Name: string;
+ Internal: boolean;
+ SecurityLevel: SecurityLevel;
+ Source: 'local';
+}
+
+// flattenProfileConfig returns a flat version of a nested ConfigMap where each property
+// can be used as the database key for the associated setting.
+export function flattenProfileConfig(
+ p?: ConfigMap,
+ prefix = ''
+): FlatConfigObject {
+ if (p === null || p === undefined) {
+ return {}
+ }
+
+ let result: FlatConfigObject = {};
+
+ Object.keys(p).forEach((key) => {
+ const childPrefix = prefix === '' ? key : `${prefix}/${key}`;
+
+ const prop = p[key];
+
+ if (isConfigMap(prop)) {
+ const flattened = flattenProfileConfig(prop, childPrefix);
+ result = mergeObjects(result, flattened);
+ return;
+ }
+
+ result[childPrefix] = prop;
+ });
+
+ return result;
+}
+
+/**
+ * Returns the current value (or null) of a setting stored in a config
+ * map by path.
+ *
+ * @param obj The ConfigMap object
+ * @param path The path of the setting separated by foward slashes.
+ */
+export function getAppSetting(
+ obj: ConfigMap | null | undefined,
+ path: string
+): T | null {
+ if (obj === null || obj === undefined) {
+ return null
+ }
+
+ const parts = path.split('/');
+
+ let iter = obj;
+ for (let idx = 0; idx < parts.length; idx++) {
+ const propName = parts[idx];
+
+ if (iter[propName] === undefined) {
+ return null;
+ }
+
+ const value = iter[propName];
+ if (idx === parts.length - 1) {
+ return value as T;
+ }
+
+ if (!isConfigMap(value)) {
+ return null;
+ }
+
+ iter = value;
+ }
+ return null;
+}
+
+export function getActualValue>(
+ s: S
+): SettingValueType {
+ if (s.Value !== undefined) {
+ return s.Value;
+ }
+ if (s.GlobalDefault !== undefined) {
+ return s.GlobalDefault;
+ }
+ return s.DefaultValue;
+}
+
+/**
+ * Sets the value of a settings inside the nested config object.
+ *
+ * @param obj THe config object
+ * @param path The path of the setting
+ * @param value The new value to set.
+ */
+export function setAppSetting(obj: ConfigObject, path: string, value: any) {
+ const parts = path.split('/');
+ if (typeof obj !== 'object' || Array.isArray(obj)) {
+ return;
+ }
+
+ let iter = obj;
+ for (let idx = 0; idx < parts.length; idx++) {
+ const propName = parts[idx];
+
+ if (idx === parts.length - 1) {
+ if (value === undefined) {
+ delete iter[propName];
+ } else {
+ iter[propName] = value;
+ }
+ return;
+ }
+
+ if (iter[propName] === undefined) {
+ iter[propName] = {};
+ }
+
+ iter = iter[propName] as ConfigMap;
+ }
+}
+
+/** Typeguard to ensure v is a ConfigMap */
+function isConfigMap(v: any): v is ConfigMap {
+ return typeof v === 'object' && !Array.isArray(v);
+}
+
+/**
+ * Returns a new flat-config object that contains values from both
+ * parameters.
+ *
+ * @param a The first config object
+ * @param b The second config object
+ */
+function mergeObjects(
+ a: FlatConfigObject,
+ b: FlatConfigObject
+): FlatConfigObject {
+ var res: FlatConfigObject = {};
+ Object.keys(a).forEach((key) => {
+ res[key] = a[key];
+ });
+ Object.keys(b).forEach((key) => {
+ res[key] = b[key];
+ });
+ return res;
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts
new file mode 100644
index 00000000..58daeb28
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts
@@ -0,0 +1,128 @@
+import { Injectable, TrackByFunction } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { distinctUntilChanged, filter, map, share, toArray } from 'rxjs/operators';
+import { BaseSetting, BoolSetting, OptionType, Setting, SettingValueType } from './config.types';
+import { PortapiService } from './portapi.service';
+
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ConfigService {
+ networkRatingEnabled$: Observable;
+
+ /**
+ * A {@link TrackByFunction} for tracking settings.
+ */
+ static trackBy: TrackByFunction = (_: number, obj: Setting) => obj.Name;
+ readonly trackBy = ConfigService.trackBy;
+
+ /** configPrefix is the database key prefix for the config db */
+ readonly configPrefix = "config:";
+
+ constructor(private portapi: PortapiService) {
+ this.networkRatingEnabled$ = this.watch("core/enableNetworkRating")
+ .pipe(
+ share({ connector: () => new BehaviorSubject(false) }),
+ )
+ }
+
+ /**
+ * Loads a configuration setting from the database.
+ *
+ * @param key The key of the configuration setting.
+ */
+ get(key: string): Observable {
+ return this.portapi.get(this.configPrefix + key);
+ }
+
+ /**
+ * Returns all configuration settings that match query. Note that in
+ * contrast to {@link PortAPI} settings values are collected into
+ * an array before being emitted. This allows simple usage in *ngFor
+ * and friends.
+ *
+ * @param query The query used to search for configuration settings.
+ */
+ query(query: string): Observable {
+ return this.portapi.query(this.configPrefix + query)
+ .pipe(
+ map(setting => setting.data),
+ toArray()
+ );
+ }
+
+ /**
+ * Save a setting.
+ *
+ * @param s The setting to save. Note that the new value should already be set to {@property Value}.
+ */
+ save(s: Setting): Observable;
+
+ /**
+ * Save a setting.
+ *
+ * @param key The key of the configuration setting
+ * @param value The new value of the setting.
+ */
+ save(key: string, value: any): Observable;
+
+ // save is overloaded, see above.
+ save(s: Setting | string, v?: any): Observable {
+ if (typeof s === 'string') {
+ return this.portapi.update(this.configPrefix + s, {
+ Key: s,
+ Value: v,
+ });
+ }
+ return this.portapi.update(this.configPrefix + s.Key, s);
+ }
+
+ /**
+ * Watch a configuration setting.
+ *
+ * @param key The key of the setting to watch.
+ */
+ watch(key: string): Observable> {
+ return this.portapi.qsub, any>>(this.configPrefix + key)
+ .pipe(
+ filter(value => value.key === this.configPrefix + key), // qsub does a query so filter for our key.
+ map(value => value.data),
+ map(value => value.Value !== undefined ? value.Value : value.DefaultValue),
+ distinctUntilChanged(),
+ )
+ }
+
+ /**
+ * Tests if a value is valid for a given option.
+ *
+ * @param spec The option specification (as returned by get()).
+ * @param value The value that should be tested.
+ */
+ validate(spec: S, value: SettingValueType) {
+ if (!spec.ValidationRegex) {
+ return;
+ }
+
+ const re = new RegExp(spec.ValidationRegex);
+
+ switch (spec.OptType) {
+ case OptionType.Int:
+ case OptionType.Bool:
+ // todo(ppacher): do we validate that?
+ return
+ case OptionType.String:
+ if (!re.test(value as string)) {
+ throw new Error(`${value} does not match ${spec.ValidationRegex}`)
+ }
+ return;
+ case OptionType.StringArray:
+ (value as string[]).forEach(v => {
+ if (!re.test(v as string)) {
+ throw new Error(`${value} does not match ${spec.ValidationRegex}`)
+ }
+ });
+ return
+ }
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts
new file mode 100644
index 00000000..99fe5d82
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts
@@ -0,0 +1,348 @@
+import { FeatureID } from './features';
+import { Record } from './portapi.types';
+import { deepClone } from './utils';
+
+/**
+ * ExpertiseLevel defines all available expertise levels.
+ */
+export enum ExpertiseLevel {
+ User = 'user',
+ Expert = 'expert',
+ Developer = 'developer',
+}
+
+export enum ExpertiseLevelNumber {
+ user = 0,
+ expert = 1,
+ developer = 2
+}
+
+export function getExpertiseLevelNumber(lvl: ExpertiseLevel): ExpertiseLevelNumber {
+ switch (lvl) {
+ case ExpertiseLevel.User:
+ return ExpertiseLevelNumber.user;
+ case ExpertiseLevel.Expert:
+ return ExpertiseLevelNumber.expert;
+ case ExpertiseLevel.Developer:
+ return ExpertiseLevelNumber.developer
+ }
+}
+
+/**
+ * OptionType defines the type of an option as stored in
+ * the backend. Note that ExternalOptionHint may be used
+ * to request a different visual representation and edit
+ * menu on a per-option basis.
+ */
+export enum OptionType {
+ String = 1,
+ StringArray = 2,
+ Int = 3,
+ Bool = 4,
+}
+
+/**
+ * Converts an option type to it's string representation.
+ *
+ * @param opt The option type to convert
+ */
+export function optionTypeName(opt: OptionType): string {
+ switch (opt) {
+ case OptionType.String:
+ return 'string';
+ case OptionType.StringArray:
+ return '[]string';
+ case OptionType.Int:
+ return 'int'
+ case OptionType.Bool:
+ return 'bool'
+ }
+}
+
+/** The actual type an option value can be */
+export type OptionValueType = string | string[] | number | boolean;
+
+/** Type-guard for string option types */
+export function isStringType(opt: OptionType, vt: OptionValueType): vt is string {
+ return opt === OptionType.String;
+}
+
+/** Type-guard for string-array option types */
+export function isStringArrayType(opt: OptionType, vt: OptionValueType): vt is string[] {
+ return opt === OptionType.StringArray;
+}
+
+/** Type-guard for number option types */
+export function isNumberType(opt: OptionType, vt: OptionValueType): vt is number {
+ return opt === OptionType.Int;
+}
+
+/** Type-guard for boolean option types */
+export function isBooleanType(opt: OptionType, vt: OptionValueType): vt is boolean {
+ return opt === OptionType.Bool;
+}
+
+/**
+ * ReleaseLevel defines the available release and maturity
+ * levels.
+ */
+export enum ReleaseLevel {
+ Stable = 0,
+ Beta = 1,
+ Experimental = 2,
+}
+
+export function releaseLevelFromName(name: 'stable' | 'beta' | 'experimental'): ReleaseLevel {
+ switch (name) {
+ case 'stable':
+ return ReleaseLevel.Stable;
+ case 'beta':
+ return ReleaseLevel.Beta;
+ case 'experimental':
+ return ReleaseLevel.Experimental;
+ }
+}
+
+/**
+ * releaseLevelName returns a string representation of the
+ * release level.
+ *
+ * @args level The release level to convert.
+ */
+export function releaseLevelName(level: ReleaseLevel): string {
+ switch (level) {
+ case ReleaseLevel.Stable:
+ return 'stable'
+ case ReleaseLevel.Beta:
+ return 'beta'
+ case ReleaseLevel.Experimental:
+ return 'experimental'
+ }
+}
+
+/**
+ * ExternalOptionHint tells the UI to use a different visual
+ * representation and edit menu that the options value would
+ * imply.
+ */
+export enum ExternalOptionHint {
+ SecurityLevel = 'security level',
+ EndpointList = 'endpoint list',
+ FilterList = 'filter list',
+ OneOf = 'one-of',
+ OrderedList = 'ordered'
+}
+
+/** A list of well-known option annotation keys. */
+export enum WellKnown {
+ DisplayHint = "safing/portbase:ui:display-hint",
+ Order = "safing/portbase:ui:order",
+ Unit = "safing/portbase:ui:unit",
+ Category = "safing/portbase:ui:category",
+ Subsystem = "safing/portbase:module:subsystem",
+ Stackable = "safing/portbase:options:stackable",
+ QuickSetting = "safing/portbase:ui:quick-setting",
+ Requires = "safing/portbase:config:requires",
+ RestartPending = "safing/portbase:options:restart-pending",
+ EndpointListVerdictNames = "safing/portmaster:ui:endpoint-list:verdict-names",
+ RequiresFeatureID = "safing/portmaster:ui:config:requires-feature",
+ RequiresUIReload = "safing/portmaster:ui:requires-reload",
+}
+
+/**
+ * Annotations describes the annoations object of a configuration
+ * setting. Well-known annotations are stricktly typed.
+ */
+export interface Annotations {
+ // Well known option annoations and their
+ // types.
+ [WellKnown.DisplayHint]?: ExternalOptionHint;
+ [WellKnown.Order]?: number;
+ [WellKnown.Unit]?: string;
+ [WellKnown.Category]?: string;
+ [WellKnown.Subsystem]?: string;
+ [WellKnown.Stackable]?: true;
+ [WellKnown.QuickSetting]?: QuickSetting | QuickSetting[] | CountrySelectionQuickSetting | CountrySelectionQuickSetting[];
+ [WellKnown.Requires]?: ValueRequirement | ValueRequirement[];
+ [WellKnown.RequiresFeatureID]?: FeatureID | FeatureID[];
+ [WellKnown.RequiresUIReload]?: unknown,
+ // Any thing else...
+ [key: string]: any;
+}
+
+export interface PossilbeValue {
+ /** Name is the name of the value and should be displayed */
+ Name: string;
+ /** Description may hold an additional description of the value */
+ Description: string;
+ /** Value is the actual value expected by the portmaster */
+ Value: T;
+}
+
+export interface QuickSetting {
+ // Name is the name of the quick setting.
+ Name: string;
+ // Value is the value that the quick-setting configures. It must match
+ // the expected value type of the annotated option.
+ Value: T;
+ // Action defines the action of the quick setting.
+ Action: 'replace' | 'merge-top' | 'merge-bottom';
+}
+
+export interface CountrySelectionQuickSetting extends QuickSetting {
+ // Filename of the flag to be used.
+ // In most cases this will be the 2-letter country code, but there are also special flags.
+ FlagID: string;
+}
+
+export interface ValueRequirement {
+ // Key is the configuration key of the required setting.
+ Key: string;
+ // Value is the required value of the linked setting.
+ Value: any;
+}
+
+/**
+ * BaseSetting describes the general shape of a portbase config setting.
+ */
+export interface BaseSetting extends Record {
+ // Value is the value of a setting.
+ Value?: T;
+ // DefaultValue is the default value of a setting.
+ DefaultValue: T;
+ // Description is a short description.
+ Description?: string;
+ // ExpertiseLevel defines the required expertise level for
+ // this setting to show up.
+ ExpertiseLevel: ExpertiseLevelNumber;
+ // Help may contain a longer help text for this option.
+ Help?: string;
+ // Key is the database key.
+ Key: string;
+ // Name is the name of the option.
+ Name: string;
+ // OptType is the option's basic type.
+ OptType: O;
+ // Annotations holds option specific annotations.
+ Annotations: Annotations;
+ // ReleaseLevel defines the release level of the feature
+ // or settings changed by this option.
+ ReleaseLevel: ReleaseLevel;
+ // RequiresRestart may be set to true if the service requires
+ // a restart after this option has been changed.
+ RequiresRestart?: boolean;
+ // ValidateRegex defines the regex used to validate this option.
+ // The regex is used in Golang but is expected to be valid in
+ // JavaScript as well.
+ ValidationRegex?: string;
+ PossibleValues?: PossilbeValue[];
+
+ // GlobalDefault holds the global default value and is used in the app settings
+ // This property is NOT defined inside the portmaster!
+ GlobalDefault?: T;
+}
+
+export type IntSetting = BaseSetting;
+export type StringSetting = BaseSetting;
+export type StringArraySetting = BaseSetting;
+export type BoolSetting = BaseSetting;
+
+/**
+ * Apply a quick setting to a value.
+ *
+ * @param current The current value of the setting.
+ * @param qs The quick setting to apply.
+ */
+export function applyQuickSetting(current: V | null, qs: QuickSetting): V | null {
+ if (qs.Action === 'replace' || !qs.Action) {
+ return deepClone(qs.Value);
+ }
+
+ if ((!Array.isArray(current) && current !== null) || !Array.isArray(qs.Value)) {
+ console.warn(`Tried to ${qs.Action} quick-setting on non-array type`);
+ return current;
+ }
+
+ const clone = deepClone(current);
+ let missing: any[] = [];
+
+ qs.Value.forEach(val => {
+ if (clone.includes(val)) {
+ return
+ }
+ missing.push(val);
+ });
+
+ if (qs.Action === 'merge-bottom') {
+ return clone.concat(missing) as V;
+ }
+
+ return missing.concat(clone) as V;
+}
+
+/**
+ * Parses the ValidationRegex of a setting and returns a list
+ * of supported values.
+ *
+ * @param s The setting to extract support values from.
+ */
+export function parseSupportedValues(s: S): SettingValueType[] {
+ if (!s.ValidationRegex) {
+ return [];
+ }
+
+ const values = s.ValidationRegex.match(/\w+/gmi);
+ const result: SettingValueType[] = [];
+
+ let converter: (s: string) => any;
+
+ switch (s.OptType) {
+ case OptionType.Bool:
+ converter = s => s === 'true';
+ break;
+ case OptionType.Int:
+ converter = s => +s;
+ break;
+ case OptionType.String:
+ case OptionType.StringArray:
+ converter = s => s
+ break
+ }
+
+ values?.forEach(val => {
+ result.push(converter(val))
+ });
+
+ return result;
+}
+
+/**
+ * isDefaultValue checks if value is the settings default value.
+ * It supports all available settings type and fallsback to use
+ * JSON encoded string comparision (JS JSON.stringify is stable).
+ */
+export function isDefaultValue(value: T | undefined | null, defaultValue: T): boolean {
+ if (value === undefined) {
+ return true;
+ }
+
+ const isObject = typeof value === 'object';
+ const isDefault = isObject
+ ? JSON.stringify(value) === JSON.stringify(defaultValue)
+ : value === defaultValue;
+
+ return isDefault;
+}
+
+/**
+ * SettingValueType is used to infer the type of a settings from it's default value.
+ * Use like this:
+ *
+ * validate(spec: S, value SettingValueType) { ... }
+ */
+export type SettingValueType = S extends { DefaultValue: infer T } ? T : any;
+
+export type Setting = IntSetting
+ | StringSetting
+ | StringArraySetting
+ | BoolSetting;
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts
new file mode 100644
index 00000000..5e5e1417
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts
@@ -0,0 +1,34 @@
+import { TrackByFunction } from '@angular/core';
+
+export enum SecurityLevel {
+ Off = 0,
+ Normal = 1,
+ High = 2,
+ Extreme = 4,
+}
+
+export enum RiskLevel {
+ Off = 'off',
+ Auto = 'auto',
+ Low = 'low',
+ Medium = 'medium',
+ High = 'high'
+}
+
+/** Interface capturing any object that has an ID member. */
+export interface Identifyable {
+ ID: string | number;
+}
+
+/** A TrackByFunction for all Identifyable objects. */
+export const trackById: TrackByFunction = (_: number, obj: Identifyable) => {
+ return obj.ID;
+}
+
+export function getEnumKey(enumLike: any, value: string | number): string {
+ if (typeof value === 'string') {
+ return value.toLowerCase()
+ }
+
+ return (enumLike[value] as string).toLowerCase()
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts
new file mode 100644
index 00000000..f0617943
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts
@@ -0,0 +1,54 @@
+import { HttpClient } from '@angular/common/http';
+import { Inject, Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class DebugAPI {
+ constructor(
+ private http: HttpClient,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
+ ) { }
+
+ ping(): Observable {
+ return this.http.get(`${this.httpAPI}/v1/ping`, {
+ responseType: 'text'
+ })
+ }
+
+ getStack(): Observable {
+ return this.http.get(`${this.httpAPI}/v1/debug/stack`, {
+ responseType: 'text'
+ })
+ }
+
+ getDebugInfo(style = 'github'): Observable {
+ return this.http.get(`${this.httpAPI}/v1/debug/info`, {
+ params: {
+ style,
+ },
+ responseType: 'text',
+ })
+ }
+
+ getCoreDebugInfo(style = 'github'): Observable {
+ return this.http.get(`${this.httpAPI}/v1/debug/core`, {
+ params: {
+ style,
+ },
+ responseType: 'text',
+ })
+ }
+
+ getProfileDebugInfo(source: string, id: string, style = 'github'): Observable {
+ return this.http.get(`${this.httpAPI}/v1/debug/network`, {
+ params: {
+ profile: `${source}/${id}`,
+ style,
+ },
+ responseType: 'text',
+ })
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts
new file mode 100644
index 00000000..658f1c1b
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/features.ts
@@ -0,0 +1,8 @@
+export enum FeatureID {
+ None = "",
+ SPN = "spn",
+ PrioritySupport = "support",
+ History = "history",
+ Bandwidth = "bw-vis",
+ VPNCompat = "vpn-compat",
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts
new file mode 100644
index 00000000..009848f4
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts
@@ -0,0 +1,106 @@
+import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
+import { Inject, Injectable, Optional } from '@angular/core';
+import { Observable, of, throwError } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
+
+export interface MetaEndpointParameter {
+ Method: string;
+ Field: string;
+ Value: string;
+ Description: string;
+}
+
+export interface MetaEndpoint {
+ Path: string;
+ MimeType: string;
+ Read: number;
+ Write: number;
+ Name: string;
+ Description: string;
+ Parameters: MetaEndpointParameter[];
+}
+
+export interface AuthPermission {
+ Read: number;
+ Write: number;
+ ReadRole: string;
+ WriteRole: string;
+}
+
+export interface MyProfileResponse {
+ profile: string;
+ source: string;
+ name: string;
+}
+
+export interface AuthKeyResponse {
+ key: string;
+ validUntil: string;
+}
+
+@Injectable()
+export class MetaAPI {
+ constructor(
+ private http: HttpClient,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://localhost:817/api',
+ ) { }
+
+ listEndpoints(): Observable {
+ return this.http.get(`${this.httpEndpoint}/v1/endpoints`)
+ }
+
+ permissions(): Observable {
+ return this.http.get(`${this.httpEndpoint}/v1/auth/permissions`)
+ }
+
+ myProfile(): Observable {
+ return this.http.get(`${this.httpEndpoint}/v1/app/profile`)
+ }
+
+ requestApplicationAccess(appName: string, read: 'user' | 'admin' = 'user', write: 'user' | 'admin' = 'user'): Observable {
+ let params = new HttpParams()
+ .set("app-name", appName)
+ .set("read", read)
+ .set("write", write)
+
+ return this.http.get(`${this.httpEndpoint}/v1/app/auth`, { params })
+ }
+
+ login(bearer: string): Observable;
+ login(username: string, password: string): Observable;
+ login(usernameOrBearer: string, password?: string): Observable {
+ let login: Observable;
+
+ if (!!password) {
+ login = this.http.get(`${this.httpEndpoint}/v1/auth/basic`, {
+ headers: {
+ 'Authorization': `Basic ${btoa(usernameOrBearer + ":" + password)}`
+ }
+ })
+ } else {
+ login = this.http.get(`${this.httpEndpoint}/v1/auth/bearer`, {
+ headers: {
+ 'Authorization': `Bearer ${usernameOrBearer}`
+ }
+ })
+ }
+
+ return login.pipe(
+ map(() => true),
+ catchError(err => {
+ if (err instanceof HttpErrorResponse) {
+ if (err.status === 401) {
+ return of(false);
+ }
+ }
+
+ return throwError(() => err)
+ })
+ )
+ }
+
+ logout(): Observable {
+ return this.http.get(`${this.httpEndpoint}/v1/auth/reset`);
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts
new file mode 100644
index 00000000..0ed13363
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/module.ts
@@ -0,0 +1,55 @@
+import { ModuleWithProviders, NgModule } from "@angular/core";
+import { AppProfileService } from "./app-profile.service";
+import { ConfigService } from "./config.service";
+import { DebugAPI } from "./debug-api.service";
+import { MetaAPI } from "./meta-api.service";
+import { Netquery } from "./netquery.service";
+import { PortapiService, PORTMASTER_HTTP_API_ENDPOINT, PORTMASTER_WS_API_ENDPOINT } from "./portapi.service";
+import { SPNService } from "./spn.service";
+import { WebsocketService } from "./websocket.service";
+
+export interface ModuleConfig {
+ httpAPI?: string;
+ websocketAPI?: string;
+}
+
+@NgModule({})
+export class PortmasterAPIModule {
+
+ /**
+ * Configures a module with additional providers.
+ *
+ * @param cfg The module configuration defining the Portmaster HTTP and Websocket API endpoints.
+ */
+ static forRoot(cfg: ModuleConfig = {}): ModuleWithProviders {
+ if (cfg.httpAPI === undefined) {
+ cfg.httpAPI = `http://${window.location.host}/api`;
+ }
+ if (cfg.websocketAPI === undefined) {
+ cfg.websocketAPI = `ws://${window.location.host}/api/database/v1`;
+ }
+
+ return {
+ ngModule: PortmasterAPIModule,
+ providers: [
+ PortapiService,
+ WebsocketService,
+ MetaAPI,
+ ConfigService,
+ AppProfileService,
+ DebugAPI,
+ Netquery,
+ SPNService,
+ {
+ provide: PORTMASTER_HTTP_API_ENDPOINT,
+ useValue: cfg.httpAPI,
+ },
+ {
+ provide: PORTMASTER_WS_API_ENDPOINT,
+ useValue: cfg.websocketAPI
+ }
+ ]
+ }
+ }
+
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
new file mode 100644
index 00000000..c0b1ec88
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
@@ -0,0 +1,543 @@
+import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
+import { Inject, Injectable } from "@angular/core";
+import { Observable, forkJoin, of } from "rxjs";
+import { catchError, map, mergeMap } from "rxjs/operators";
+import { AppProfileService } from "./app-profile.service";
+import { AppProfile } from "./app-profile.types";
+import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types";
+import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from "./portapi.service";
+import { Container } from "postcss";
+
+export interface FieldSelect {
+ field: string;
+}
+
+export interface FieldAsSelect {
+ $field: {
+ field: string;
+ as: string;
+ }
+}
+
+export interface Count {
+ $count: {
+ field: string;
+ distinct?: boolean;
+ as?: string;
+ }
+}
+
+export interface Sum {
+ $sum: {
+ condition: Condition;
+ as: string;
+ distinct?: boolean;
+ } | {
+ field: string;
+ as: string;
+ distinct?: boolean;
+ }
+}
+
+export interface Min {
+ $min: {
+ condition: Condition;
+ as: string;
+ distinct?: boolean;
+ } | {
+ field: string;
+ as: string;
+ distinct?: boolean;
+ }
+}
+
+export interface Distinct {
+ $distinct: string;
+}
+
+export type Select = FieldSelect | FieldAsSelect | Count | Distinct | Sum | Min;
+
+export interface Equal {
+ $eq: any;
+}
+
+export interface NotEqual {
+ $ne: any;
+}
+
+export interface Like {
+ $like: string;
+}
+
+export interface In {
+ $in: any[];
+}
+
+export interface NotIn {
+ $notin: string[];
+}
+
+export interface Greater {
+ $gt: number;
+}
+
+export interface GreaterOrEqual {
+ $ge: number;
+}
+
+export interface Less {
+ $lt: number;
+}
+
+export interface LessOrEqual {
+ $le: number;
+}
+
+export type Matcher = Equal | NotEqual | Like | In | NotIn | Greater | GreaterOrEqual | Less | LessOrEqual;
+
+export interface OrderBy {
+ field: string;
+ desc?: boolean;
+}
+
+export interface Condition {
+ [key: string]: string | Matcher | (string | Matcher)[];
+}
+
+export interface TextSearch {
+ fields: string[];
+ value: string;
+}
+
+export enum Database {
+ Live = "main",
+ History = "history"
+}
+
+export interface Query {
+ select?: string | Select | (Select | string)[];
+ query?: Condition;
+ orderBy?: string | OrderBy | (OrderBy | string)[];
+ textSearch?: TextSearch;
+ groupBy?: string[];
+ pageSize?: number;
+ page?: number;
+ databases?: Database[];
+}
+
+export interface NetqueryConnection {
+ id: string;
+ allowed: boolean | null;
+ profile: string;
+ path: string;
+ type: 'dns' | 'ip';
+ external: boolean;
+ ip_version: number;
+ ip_protocol: number;
+ local_ip: string;
+ local_port: number;
+ remote_ip: string;
+ remote_port: number;
+ domain: string;
+ country: string;
+ asn: number;
+ as_owner: string;
+ latitude: number;
+ longitude: number;
+ scope: IPScope;
+ verdict: Verdict;
+ started: string;
+ ended: string;
+ tunneled: boolean;
+ encrypted: boolean;
+ internal: boolean;
+ direction: 'inbound' | 'outbound';
+ profile_revision: number;
+ exit_node?: string;
+ extra_data?: {
+ pid?: number;
+ processCreatedAt?: number;
+ cname?: string[];
+ blockedByLists?: string[];
+ blockedEntities?: string[];
+ reason?: Reason;
+ tunnel?: TunnelContext;
+ dns?: DNSContext;
+ tls?: TLSContext;
+ };
+
+ profile_name: string;
+ active: boolean;
+ bytes_received: number;
+ bytes_sent: number;
+}
+
+export interface ChartResult {
+ timestamp: number;
+ value: number;
+ countBlocked: number;
+}
+
+export interface QueryResult extends Partial {
+ [key: string]: any;
+}
+
+export interface Identities {
+ exit_node: string;
+ count: number;
+}
+
+export interface IProfileStats {
+ ID: string;
+ Name: string;
+
+ size: number;
+ empty: boolean;
+ identities: Identities[];
+ countAllowed: number;
+ countUnpermitted: number;
+ countAliveConnections: number;
+ bytes_sent: number;
+ bytes_received: number;
+}
+
+type BatchResponse = {
+ [key in keyof T]: QueryResult[]
+}
+
+interface BatchRequest {
+ [key: string]: Query
+}
+
+interface BandwidthBaseResult {
+ timestamp: number;
+ incoming: number;
+ outgoing: number;
+}
+
+export type ConnKeys = keyof NetqueryConnection
+
+export type BandwidthChartResult = {
+ [key in K]: NetqueryConnection[K];
+} & BandwidthBaseResult
+
+export type ProfileBandwidthChartResult = BandwidthChartResult<'profile'>;
+
+export type ConnectionBandwidthChartResult = BandwidthChartResult<'id'>;
+
+@Injectable({ providedIn: 'root' })
+export class Netquery {
+ constructor(
+ private http: HttpClient,
+ private profileService: AppProfileService,
+ private portapi: PortapiService,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
+ ) { }
+
+ query(query: Query, origin: string): Observable {
+ return this.http.post<{ results: QueryResult[] }>(`${this.httpAPI}/v1/netquery/query`, query, {
+ params: new HttpParams().set("origin", origin)
+ })
+ .pipe(map(res => res.results || []));
+ }
+
+ batch(queries: T): Observable> {
+ return this.http.post>(`${this.httpAPI}/v1/netquery/query/batch`, queries)
+ }
+
+ cleanProfileHistory(profileIDs: string | string[]): Observable> {
+ return this.http.post(`${this.httpAPI}/v1/netquery/history/clear`,
+ {
+ profileIDs: Array.isArray(profileIDs) ? profileIDs : [profileIDs]
+ },
+ {
+ observe: 'response',
+ responseType: 'text',
+ reportProgress: false,
+ }
+ )
+ }
+
+ profileBandwidthChart(profile?: string[], interval?: number): Observable<{ [profile: string]: ProfileBandwidthChartResult[] }> {
+ const cond: Condition = {}
+ if (!!profile) {
+ cond['profile'] = profile
+ }
+
+ return this.bandwidthChart(cond, ['profile'], interval)
+ .pipe(
+ map(results => {
+ const obj: {
+ [connId: string]: ProfileBandwidthChartResult[]
+ } = {};
+
+ results?.forEach(row => {
+ const arr = obj[row.profile] || []
+ arr.push(row)
+ obj[row.profile] = arr
+ })
+
+ return obj
+ })
+ )
+ }
+
+ bandwidthChart(query: Condition, groupBy?: K[], interval?: number): Observable[]> {
+ return this.http.post<{ results: BandwidthChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/bandwidth`, {
+ interval,
+ groupBy,
+ query,
+ })
+ .pipe(
+ map(response => response.results),
+ )
+ }
+
+ connectionBandwidthChart(connIds: string[], interval?: number): Observable<{ [connId: string]: ConnectionBandwidthChartResult[] }> {
+ const cond: Condition = {}
+ if (!!connIds) {
+ cond['id'] = connIds
+ }
+
+ return this.bandwidthChart(cond, ['id'], interval)
+ .pipe(
+ map(results => {
+ const obj: {
+ [connId: string]: ConnectionBandwidthChartResult[]
+ } = {};
+
+ results?.forEach(row => {
+ const arr = obj[row.id] || []
+ arr.push(row)
+ obj[row.id] = arr
+ })
+
+ return obj
+ })
+ )
+ }
+
+ activeConnectionChart(cond: Condition, textSearch?: TextSearch): Observable {
+ return this.http.post<{ results: ChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/connection-active`, {
+ query: cond,
+ textSearch,
+ })
+ .pipe(map(res => {
+ const now = new Date();
+
+ let data: ChartResult[] = [];
+
+ let lastPoint: ChartResult | null = {
+ timestamp: Math.floor(now.getTime() / 1000 - 600),
+ value: 0,
+ countBlocked: 0,
+ };
+ res.results?.forEach(point => {
+ if (!!lastPoint && lastPoint.timestamp < (point.timestamp - 10)) {
+ for (let i = lastPoint.timestamp; i < point.timestamp; i += 10) {
+ data.push({
+ timestamp: i,
+ value: 0,
+ countBlocked: 0,
+ })
+ }
+ }
+ data.push(point);
+ lastPoint = point;
+ })
+
+ const lastPointTs = Math.round(now.getTime() / 1000);
+ if (!!lastPoint && lastPoint.timestamp < (lastPointTs - 20)) {
+ for (let i = lastPoint.timestamp; i < lastPointTs; i += 20) {
+ data.push({
+ timestamp: i,
+ value: 0,
+ countBlocked: 0
+ })
+ }
+ }
+
+ return data;
+ }));
+ }
+
+ getActiveProfileIDs(): Observable {
+ return this.query({
+ select: [
+ 'profile',
+ ],
+ groupBy: [
+ 'profile',
+ ],
+ }, 'get-active-profile-ids').pipe(
+ map(result => {
+ return result.map(res => res.profile!);
+ })
+ )
+ }
+
+ getActiveProfiles(): Observable {
+ return this.getActiveProfileIDs()
+ .pipe(
+ mergeMap(profiles => forkJoin(profiles.map(pid => this.profileService.getAppProfile(pid))))
+ )
+ }
+
+ getProfileStats(query?: Condition): Observable {
+ let profileCache = new Map();
+
+ return this.batch({
+ verdicts: {
+ select: [
+ 'profile',
+ 'verdict',
+ { $count: { field: '*', as: 'totalCount' } },
+ ],
+ groupBy: [
+ 'profile',
+ 'verdict',
+ ],
+ query: query,
+ },
+
+ conns: {
+ select: [
+ 'profile',
+ { $count: { field: '*', as: 'totalCount' } },
+ { $count: { field: 'ended', as: 'countEnded' } },
+ { $sum: { field: 'bytes_sent', as: 'bytes_sent' } },
+ { $sum: { field: 'bytes_received', as: 'bytes_received' } },
+ ],
+ groupBy: [
+ 'profile',
+ ],
+ query: query,
+ },
+
+ identities: {
+ select: [
+ 'profile',
+ 'exit_node',
+ { $count: { field: '*', as: 'totalCount' } }
+ ],
+ groupBy: [
+ 'profile',
+ 'exit_node',
+ ],
+ query: {
+ ...query,
+ exit_node: {
+ $ne: "",
+ },
+ },
+ }
+ }).pipe(
+ map(result => {
+ let statsMap = new Map();
+
+ const getOrCreate = (id: string) => {
+ let stats = statsMap.get(id) || {
+ ID: id,
+ Name: 'Deleted',
+ countAliveConnections: 0,
+ countAllowed: 0,
+ countUnpermitted: 0,
+ empty: true,
+ identities: [],
+ size: 0,
+ bytes_received: 0,
+ bytes_sent: 0
+ };
+
+ statsMap.set(id, stats);
+ return stats;
+ }
+ result.verdicts?.forEach(res => {
+ const stats = getOrCreate(res.profile!);
+
+ switch (res.verdict) {
+ case Verdict.Accept:
+ case Verdict.RerouteToNs:
+ case Verdict.RerouteToTunnel:
+ case Verdict.Undeterminable:
+ stats.size += res.totalCount
+ stats.countAllowed += res.totalCount;
+ break;
+
+ case Verdict.Block:
+ case Verdict.Drop:
+ case Verdict.Failed:
+ case Verdict.Undecided:
+ stats.size += res.totalCount
+ stats.countUnpermitted += res.totalCount;
+ break;
+ }
+
+ stats.empty = stats.size == 0;
+ })
+
+ result.conns?.forEach(res => {
+ const stats = getOrCreate(res.profile!);
+
+ stats.countAliveConnections = res.totalCount - res.countEnded;
+ stats.bytes_received += res.bytes_received!;
+ stats.bytes_sent += res.bytes_sent!;
+ })
+
+ result.identities?.forEach(res => {
+ const stats = getOrCreate(res.profile!);
+
+ let ident = stats.identities.find(value => value.exit_node === res.exit_node)
+ if (!ident) {
+ ident = {
+ count: 0,
+ exit_node: res.exit_node!,
+ }
+ stats.identities.push(ident);
+ }
+
+ ident.count += res.totalCount;
+ })
+
+ return Array.from(statsMap.values())
+ }),
+ mergeMap(stats => {
+ return forkJoin(stats.map(p => {
+ if (profileCache.has(p.ID)) {
+ return of(profileCache.get(p.ID)!);
+ }
+ return this.profileService.getAppProfile(p.ID)
+ .pipe(catchError(err => {
+ return of(null)
+ }))
+ }))
+ .pipe(
+ map((profiles: (AppProfile | null)[]) => {
+ profileCache = new Map();
+
+ let lm = new Map();
+ stats.forEach(stat => lm.set(stat.ID, stat));
+
+ profiles
+ .forEach(p => {
+ if (!p) {
+ return
+ }
+
+ profileCache.set(`${p.Source}/${p.ID}`, p)
+
+ let stat = lm.get(`${p.Source}/${p.ID}`)
+ if (!stat) {
+ return;
+ }
+
+ stat.Name = p.Name
+ })
+
+ return Array.from(lm.values())
+ })
+ )
+ })
+ )
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
new file mode 100644
index 00000000..6cdef998
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
@@ -0,0 +1,314 @@
+import { Record } from './portapi.types';
+
+export enum Verdict {
+ Undecided = 0,
+ Undeterminable = 1,
+ Accept = 2,
+ Block = 3,
+ Drop = 4,
+ RerouteToNs = 5,
+ RerouteToTunnel = 6,
+ Failed = 7
+}
+
+export enum IPProtocol {
+ ICMP = 1,
+ IGMP = 2,
+ TCP = 6,
+ UDP = 17,
+ ICMPv6 = 58,
+ UDPLite = 136,
+ RAW = 255, // TODO(ppacher): what is RAW used for?
+}
+
+export enum IPVersion {
+ V4 = 4,
+ V6 = 6,
+}
+
+export enum IPScope {
+ Invalid = -1,
+ Undefined = 0,
+ HostLocal = 1,
+ LinkLocal = 2,
+ SiteLocal = 3,
+ Global = 4,
+ LocalMulticast = 5,
+ GlobalMulitcast = 6
+}
+
+let globalScopes = new Set([IPScope.GlobalMulitcast, IPScope.Global])
+let localScopes = new Set([IPScope.SiteLocal, IPScope.LinkLocal, IPScope.LocalMulticast])
+
+// IsGlobalScope returns true if scope represents a globally
+// routed destination.
+export function IsGlobalScope(scope: IPScope): scope is IPScope.GlobalMulitcast | IPScope.Global {
+ return globalScopes.has(scope);
+}
+
+// IsLocalScope returns true if scope represents a locally
+// routed destination.
+export function IsLANScope(scope: IPScope): scope is IPScope.SiteLocal | IPScope.LinkLocal | IPScope.LocalMulticast {
+ return localScopes.has(scope);
+}
+
+// IsLocalhost returns true if scope represents localhost.
+export function IsLocalhost(scope: IPScope): scope is IPScope.HostLocal {
+ return scope === IPScope.HostLocal;
+}
+
+const deniedVerdicts = new Set([
+ Verdict.Drop,
+ Verdict.Block,
+])
+// IsDenied returns true if the verdict v represents a
+// deny or block decision.
+export function IsDenied(v: Verdict): boolean {
+ return deniedVerdicts.has(v);
+}
+
+export interface CountryInfo {
+ Code: string;
+ Name: string;
+ Center: GeoCoordinates;
+ Continent: ContinentInfo;
+}
+
+export interface ContinentInfo {
+ Code: string;
+ Region: string;
+ Name: string;
+}
+
+export interface GeoCoordinates {
+ AccuracyRadius: number;
+ Latitude: number;
+ Longitude: number;
+}
+
+export const UnknownLocation: GeoCoordinates = {
+ AccuracyRadius: 0,
+ Latitude: 0,
+ Longitude: 0
+}
+
+export interface IntelEntity {
+ // Protocol is the IP protocol used to connect/communicate
+ // the the described entity.
+ Protocol: IPProtocol;
+ // Port is the remote port number used.
+ Port: number;
+ // Domain is the domain name of the entity. This may either
+ // be the domain name used in the DNS request or the
+ // named returned from reverse PTR lookup.
+ Domain: string;
+ // CNAME is a list of CNAMEs that have been used
+ // to resolve this entity.
+ CNAME: string[] | null;
+ // IP is the IP address of the entity.
+ IP: string;
+ // IPScope holds the classification of the IP address.
+ IPScope: IPScope;
+ // Country holds the country of residence of the IP address.
+ Country: string;
+ // ASN holds the number of the autonoumous system that operates
+ // the IP.
+ ASN: number;
+ // ASOrg holds the AS owner name.
+ ASOrg: string;
+ // Coordinates contains the geographic coordinates of the entity.
+ Coordinates: GeoCoordinates | null;
+ // BlockedByLists holds a list of filter list IDs that
+ // would have blocked the entity.
+ BlockedByLists: string[] | null;
+ // BlockedEntities holds a list of entities that have been
+ // blocked by filter lists. Those entities can be ASNs, domains,
+ // CNAMEs, IPs or Countries.
+ BlockedEntities: string[] | null;
+ // ListOccurences maps the blocked entity (see BlockedEntities)
+ // to a list of filter-list IDs that contains it.
+ ListOccurences: { [key: string]: string[] } | null;
+}
+
+export enum ScopeIdentifier {
+ IncomingHost = "IH",
+ IncomingLAN = "IL",
+ IncomingInternet = "II",
+ IncomingInvalid = "IX",
+ PeerHost = "PH",
+ PeerLAN = "PL",
+ PeerInternet = "PI",
+ PeerInvalid = "PX"
+}
+
+export const ScopeTranslation: { [key: string]: string } = {
+ [ScopeIdentifier.IncomingHost]: "Device-Local Incoming",
+ [ScopeIdentifier.IncomingLAN]: "LAN Incoming",
+ [ScopeIdentifier.IncomingInternet]: "Internet Incoming",
+ [ScopeIdentifier.PeerHost]: "Device-Local Outgoing",
+ [ScopeIdentifier.PeerLAN]: "LAN Peer-to-Peer",
+ [ScopeIdentifier.PeerInternet]: "Internet Peer-to-Peer",
+ [ScopeIdentifier.IncomingInvalid]: "N/A",
+ [ScopeIdentifier.PeerInvalid]: "N/A",
+}
+
+export interface ProcessContext {
+ BinaryPath: string;
+ ProcessName: string;
+ ProfileName: string;
+ PID: number;
+ Profile: string;
+ Source: string
+}
+
+// Reason justifies the decision on a connection
+// verdict.
+export interface Reason {
+ // Msg holds a human readable message of the reason.
+ Msg: string;
+ // OptionKey, if available, holds the key of the
+ // configuration option that caused the verdict.
+ OptionKey: string;
+ // Profile holds the profile the option setting has
+ // been configured in.
+ Profile: string;
+ // Context may holds additional data about the reason.
+ Context: any;
+}
+
+export enum ConnectionType {
+ Undefined = 0,
+ IPConnection = 1,
+ DNSRequest = 2
+}
+
+export function IsDNSRequest(t: ConnectionType): t is ConnectionType.DNSRequest {
+ return t === ConnectionType.DNSRequest;
+}
+
+export function IsIPConnection(t: ConnectionType): t is ConnectionType.IPConnection {
+ return t === ConnectionType.IPConnection;
+}
+
+export interface DNSContext {
+ Domain: string;
+ ServedFromCache: boolean;
+ RequestingNew: boolean;
+ IsBackup: boolean;
+ Filtered: boolean;
+ FilteredEntries: string[], // RR
+ Question: 'A' | 'AAAA' | 'MX' | 'TXT' | 'SOA' | 'SRV' | 'PTR' | 'NS' | string;
+ RCode: 'NOERROR' | 'SERVFAIL' | 'NXDOMAIN' | 'REFUSED' | string;
+ Modified: string;
+ Expires: string;
+}
+
+export interface TunnelContext {
+ Path: TunnelNode[];
+ PathCost: number;
+ RoutingAlg: 'default';
+}
+
+export interface GeoIPInfo {
+ IP: string;
+ Country: string;
+ ASN: number;
+ ASOwner: string;
+}
+
+export interface TunnelNode {
+ ID: string;
+ Name: string;
+ IPv4?: GeoIPInfo;
+ IPv6?: GeoIPInfo;
+
+}
+
+export interface CertInfo {
+ Subject: string;
+ Issuer: string;
+ AlternateNames: string[];
+ NotBefore: dateType;
+ NotAfter: dateType;
+}
+
+export interface TLSContext {
+ Version: string;
+ VersionRaw: number;
+ SNI: string;
+ Chain: CertInfo[][];
+}
+
+export interface Connection extends Record {
+ // ID is a unique ID for the connection.
+ ID: string;
+ // Type defines the connection type.
+ Type: ConnectionType;
+ // TLS may holds additional data for the TLS
+ // session.
+ TLS: TLSContext | null;
+ // DNSContext holds additional data about the DNS request for
+ // this connection.
+ DNSContext: DNSContext | null;
+ // TunnelContext holds additional data about the SPN tunnel used for
+ // the connection.
+ TunnelContext: TunnelContext | null;
+ // Scope defines the scope of the connection. It's an somewhat
+ // weired field that may contain a ScopeIdentifier or a string.
+ // In case of a string it may eventually be interpreted as a
+ // domain name.
+ Scope: ScopeIdentifier | string;
+ // IPVersion is the version of the IP protocol used.
+ IPVersion: IPVersion;
+ // Inbound is true if the connection is incoming to
+ // hte local system.
+ Inbound: boolean;
+ // IPProtocol is the protocol used by the connection.
+ IPProtocol: IPProtocol;
+ // LocalIP is the local IP address that is involved into
+ // the connection.
+ LocalIP: string;
+ // LocalIPScope holds the classification of the local IP
+ // address;
+ LocalIPScope: IPScope;
+ // LocalPort is the local port that is involved into the
+ // connection.
+ LocalPort: number;
+ // Entity describes the remote entity that is part of the
+ // connection.
+ Entity: IntelEntity;
+ // Verdict defines the final verdict.
+ Verdict: Verdict;
+ // Reason is the reason justifying the verdict of the connection.
+ Reason: Reason;
+ // Started holds the number of seconds in UNIX epoch time at which
+ // the connection was initiated.
+ Started: number;
+ // End dholds the number of seconds in UNIX epoch time at which
+ // the connection was considered terminated.
+ Ended: number;
+ // Tunneled is set to true if the connection was tunneled through the
+ // SPN.
+ Tunneled: boolean;
+ // VerdictPermanent is set to true if the connection was marked and
+ // handed back to the operating system.
+ VerdictPermanent: boolean;
+ // Inspecting is set to true if the connection is being inspected.
+ Inspecting: boolean;
+ // Encrypted is set to true if the connection is estimated as being
+ // encrypted. Interpreting this field must be done with care!
+ Encrypted: boolean;
+ // Internal is set to true if this connection is done by the Portmaster
+ // or any associated helper processes/binaries itself.
+ Internal: boolean;
+ // ProcessContext holds additional information about the process
+ // that initated the connection.
+ ProcessContext: ProcessContext;
+ // ProfileRevisionCounter is used to track changes to the process
+ // profile.
+ ProfileRevisionCounter: number;
+}
+
+export interface ReasonContext {
+ [key: string]: any;
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts
new file mode 100644
index 00000000..4f243ecd
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts
@@ -0,0 +1,1011 @@
+import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
+import {
+ Inject,
+ Injectable,
+ InjectionToken,
+ isDevMode,
+ NgZone,
+} from '@angular/core';
+import { BehaviorSubject, Observable, Observer, of } from 'rxjs';
+import {
+ concatMap,
+ delay,
+ filter,
+ map,
+ retryWhen,
+ takeWhile,
+ tap,
+} from 'rxjs/operators';
+import { WebSocketSubject } from 'rxjs/webSocket';
+import {
+ DataReply,
+ deserializeMessage,
+ DoneReply,
+ ImportResult,
+ InspectedActiveRequest,
+ isCancellable,
+ isDataReply,
+ ProfileImportResult,
+ Record,
+ ReplyMessage,
+ Requestable,
+ RequestMessage,
+ RequestType,
+ RetryableOpts,
+ retryPipeline,
+ serializeMessage,
+ WatchOpts,
+} from './portapi.types';
+import { WebsocketService } from './websocket.service';
+
+export const PORTMASTER_WS_API_ENDPOINT = new InjectionToken(
+ 'PortmasterWebsocketEndpoint'
+);
+export const PORTMASTER_HTTP_API_ENDPOINT = new InjectionToken(
+ 'PortmasterHttpApiEndpoint'
+);
+
+export const RECONNECT_INTERVAL = 2000;
+
+let uniqueRequestId = 0;
+
+interface PendingMethod {
+ observer: Observer;
+ request: RequestMessage;
+}
+
+@Injectable()
+export class PortapiService {
+ /** The actual websocket connection, auto-(re)connects on subscription */
+ private ws$: WebSocketSubject | null;
+
+ /** used to emit changes to our "connection state" */
+ private connectedSubject = new BehaviorSubject(false);
+
+ /** A map to multiplex websocket messages to the actual observer/initator */
+ private _streams$ = new Map>>();
+
+ /** Map to keep track of "still-to-send" requests when we are currently disconnected */
+ private _pendingCalls$ = new Map();
+
+ /** Whether or not we are currently connected. */
+ get connected$() {
+ return this.connectedSubject.asObservable();
+ }
+
+ /** @private DEBUGGING ONLY - keeps track of current requests and supports injecting messages */
+ readonly activeRequests = new BehaviorSubject<{
+ [key: string]: InspectedActiveRequest;
+ }>({});
+
+ constructor(
+ private websocketFactory: WebsocketService,
+ private ngZone: NgZone,
+ private http: HttpClient,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpEndpoint: string,
+ @Inject(PORTMASTER_WS_API_ENDPOINT) private wsEndpoint: string
+ ) {
+ // create a new websocket connection that will auto-connect
+ // on the first subscription and will automatically reconnect
+ // with consecutive subscribers.
+ this.ws$ = this.createWebsocket();
+
+ // no need to keep a reference to the subscription as we're not going
+ // to unsubscribe ...
+ this.ws$
+ .pipe(
+ retryWhen((errors) =>
+ errors.pipe(
+ // use concatMap to keep the errors in order and make sure
+ // they don't execute in parallel.
+ concatMap((e, i) =>
+ of(e).pipe(
+ // We need to forward the error to all streams here because
+ // due to the retry feature the subscriber below won't see
+ // any error at all.
+ tap(() => {
+ this._streams$.forEach((observer) => observer.error(e));
+ this._streams$.clear();
+ }),
+ delay(1000)
+ )
+ )
+ )
+ )
+ )
+ .subscribe(
+ (msg) => {
+ const observer = this._streams$.get(msg.id);
+ if (!observer) {
+ // it's expected that we receive done messages from time to time here
+ // as portmaster sends a "done" message after we "cancel" a subscription
+ // and we already remove the observer from _streams$ if the subscription
+ // is unsubscribed. So just hide that warning message for "done"
+ if (msg.type !== 'done') {
+ console.warn(
+ `Received message for unknown request id ${msg.id} (type=${msg.type})`,
+ msg
+ );
+ }
+ return;
+ }
+
+ // forward the message to the actual stream.
+ observer.next(msg as ReplyMessage);
+ },
+ console.error,
+ () => {
+ // This should actually never happen but if, make sure
+ // we handle it ...
+ this._streams$.forEach((observer) => observer.complete());
+ this._streams$.clear();
+ }
+ );
+ }
+
+ /** Triggers a restart of the portmaster service */
+ restartPortmaster(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/core/restart`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** Triggers a shutdown of the portmaster service */
+ shutdownPortmaster(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/core/shutdown`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** Force the portmaster to check for updates */
+ checkForUpdates(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/updates/check`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ reportProgress: false,
+ });
+ }
+
+ /** Force a reload of the UI assets */
+ reloadUI(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/ui/reload`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** Clear DNS cache */
+ clearDNSCache(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/dns/clear`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** Reset the broadcast notifications state */
+ resetBroadcastState(): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/broadcasts/reset-state`,
+ undefined,
+ { observe: 'response', responseType: 'arraybuffer' }
+ );
+ }
+
+ /** Re-initialize the SPN */
+ reinitSPN(): Observable {
+ return this.http.post(`${this.httpEndpoint}/v1/spn/reinit`, undefined, {
+ observe: 'response',
+ responseType: 'arraybuffer',
+ });
+ }
+
+ /** Cleans up the history database by applying history retention settings */
+ cleanupHistory(): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/netquery/history/cleanup`,
+ undefined,
+ { observe: 'response', responseType: 'arraybuffer' }
+ );
+ }
+
+ /** Requests a resource from the portmaster as application/json and automatically parses the response body*/
+ getResource(resource: string): Observable;
+
+ /** Requests a resource from the portmaster as text */
+ getResource(resource: string, type: string): Observable>;
+
+ getResource(
+ resource: string,
+ type?: string
+ ): Observable | any> {
+ if (type !== undefined) {
+ return this.http.get(`${this.httpEndpoint}/v1/updates/get/${resource}`, {
+ headers: new HttpHeaders({ Accept: type }),
+ observe: 'response',
+ responseType: 'text',
+ });
+ }
+
+ return this.http.get(
+ `${this.httpEndpoint}/v1/updates/get/${resource}`,
+ {
+ headers: new HttpHeaders({ Accept: 'application/json' }),
+ responseType: 'json',
+ }
+ );
+ }
+
+ /** Export one or more settings, either from global settings or a specific profile */
+ exportSettings(
+ keys: string[],
+ from: 'global' | string = 'global'
+ ): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/settings/export`,
+ {
+ from,
+ keys,
+ },
+ {
+ headers: new HttpHeaders({ Accept: 'text/yaml' }),
+ responseType: 'text',
+ observe: 'body',
+ }
+ );
+ }
+
+ /** Validate a settings import for a given target */
+ validateSettingsImport(
+ blob: string | Blob,
+ target: string | 'global' = 'global',
+ mimeType: string = 'text/yaml'
+ ): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/settings/import`,
+ {
+ target,
+ rawExport: blob.toString(),
+ rawMime: mimeType,
+ validateOnly: true,
+ }
+ );
+ }
+
+ /** Import settings into a given target */
+ importSettings(
+ blob: string | Blob,
+ target: string | 'global' = 'global',
+ mimeType: string = 'text/yaml',
+ reset = false,
+ allowUnknown = false
+ ): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/settings/import`,
+ {
+ target,
+ rawExport: blob.toString(),
+ rawMime: mimeType,
+ validateOnly: false,
+ reset,
+ allowUnknown,
+ }
+ );
+ }
+
+ /** Import a profile */
+ importProfile(
+ blob: string | Blob,
+ mimeType: string = 'text/yaml',
+ reset = false,
+ allowUnknown = false,
+ allowReplaceProfiles = false
+ ): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/profile/import`,
+ {
+ rawExport: blob.toString(),
+ rawMime: mimeType,
+ validateOnly: false,
+ reset,
+ allowUnknown,
+ allowReplaceProfiles,
+ }
+ );
+ }
+
+ /** Import a profile */
+ validateProfileImport(
+ blob: string | Blob,
+ mimeType: string = 'text/yaml'
+ ): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/profile/import`,
+ {
+ rawExport: blob.toString(),
+ rawMime: mimeType,
+ validateOnly: true,
+ }
+ );
+ }
+
+ /** Export one or more settings, either from global settings or a specific profile */
+ exportProfile(id: string): Observable {
+ return this.http.post(
+ `${this.httpEndpoint}/v1/sync/profile/export`,
+ {
+ id,
+ },
+ {
+ headers: new HttpHeaders({ Accept: 'text/yaml' }),
+ responseType: 'text',
+ observe: 'body',
+ }
+ );
+ }
+
+ /** Merge multiple profiles into one primary profile. */
+ mergeProfiles(
+ name: string,
+ primary: string,
+ secondaries: string[]
+ ): Observable {
+ return this.http
+ .post<{ new: string }>(`${this.httpEndpoint}/v1/profile/merge`, {
+ name: name,
+ to: primary,
+ from: secondaries,
+ })
+ .pipe(map((response) => response.new));
+ }
+
+ /**
+ * Injects an event into a module to trigger certain backend
+ * behavior.
+ *
+ * @deprecated - Use the HTTP API instead.
+ *
+ * @param module The name of the module to inject
+ * @param kind The event kind to inject
+ */
+ bridgeAPI(call: string, method: string): Observable {
+ return this.create(`api:${call}`, {
+ Method: method,
+ }).pipe(map(() => { }));
+ }
+
+ /**
+ * Flushes all pending method calls that have been collected
+ * while we were not connected to the portmaster API.
+ */
+ private _flushPendingMethods() {
+ const count = this._pendingCalls$.size;
+ try {
+ this._pendingCalls$.forEach((req, key) => {
+ // It's fine if we throw an error here!
+ this.ws$!.next(req.request);
+ this._streams$.set(req.request.id, req.observer);
+ this._pendingCalls$.delete(key);
+ });
+ } catch (err) {
+ // we failed to send the pending calls because the
+ // websocket connection just broke.
+ console.error(
+ `Failed to flush pending calls, ${this._pendingCalls$.size} left: `,
+ err
+ );
+ }
+
+ console.log(`Successfully flushed all (${count}) pending calles`);
+ }
+
+ /**
+ * Allows to inspect currently active requests.
+ */
+ inspectActiveRequests(): { [key: string]: InspectedActiveRequest } {
+ return this.activeRequests.getValue();
+ }
+
+ /**
+ * Loads a database entry. The returned observable completes
+ * after the entry has been loaded.
+ *
+ * @param key The database key of the entry to load.
+ */
+ get(key: string): Observable {
+ return this.request('get', { key }).pipe(map((res) => res.data));
+ }
+
+ /**
+ * Searches for multiple database entries at once. Each entry
+ * is streams via the returned observable. The observable is
+ * closed after the last entry has been published.
+ *
+ * @param query The query used to search the database.
+ */
+ query(query: string): Observable> {
+ return this.request('query', { query });
+ }
+
+ /**
+ * Subscribes for updates on entries of the selected query.
+ *
+ * @param query The query use to subscribe.
+ */
+ sub(
+ query: string,
+ opts: RetryableOpts = {}
+ ): Observable> {
+ return this.request('sub', { query }).pipe(retryPipeline(opts));
+ }
+
+ /**
+ * Subscribes for updates on entries of the selected query and
+ * ensures entries are stream once upon subscription.
+ *
+ * @param query The query use to subscribe.
+ * @todo(ppacher): check what a ok/done message mean here.
+ */
+ qsub(
+ query: string,
+ opts?: RetryableOpts
+ ): Observable>;
+ qsub(
+ query: string,
+ opts: RetryableOpts,
+ _: { forwardDone: true }
+ ): Observable | DoneReply>;
+ qsub(
+ query: string,
+ opts: RetryableOpts = {},
+ { forwardDone }: { forwardDone?: true } = {}
+ ): Observable> {
+ return this.request('qsub', { query }, { forwardDone }).pipe(
+ retryPipeline(opts)
+ );
+ }
+
+ /**
+ * Creates a new database entry.
+ *
+ * @warn create operations do not validate the type of data
+ * to be overwritten (for keys that does already exist).
+ * Use {@function insert} for more validation.
+ *
+ * @param key The database key for the entry.
+ * @param data The actual data for the entry.
+ */
+ create(key: string, data: any): Observable {
+ data = this.stripMeta(data);
+ return this.request('create', { key, data }).pipe(map(() => { }));
+ }
+
+ /**
+ * Updates an existing entry.
+ *
+ * @param key The database key for the entry
+ * @param data The actual, updated entry data.
+ */
+ update(key: string, data: any): Observable {
+ data = this.stripMeta(data);
+ return this.request('update', { key, data }).pipe(map(() => { }));
+ }
+
+ /**
+ * Creates a new database entry.
+ *
+ * @param key The database key for the entry.
+ * @param data The actual data for the entry.
+ * @todo(ppacher): check what's different to create().
+ */
+ insert(key: string, data: any): Observable {
+ data = this.stripMeta(data);
+ return this.request('insert', { key, data }).pipe(map(() => { }));
+ }
+
+ /**
+ * Deletes an existing database entry.
+ *
+ * @param key The key of the database entry to delete.
+ */
+ delete(key: string): Observable {
+ return this.request('delete', { key }).pipe(map(() => { }));
+ }
+
+ /**
+ * Watch a database key for modifications. If the
+ * websocket connection is lost or an error is returned
+ * watch will automatically retry after retryDelay
+ * milliseconds. It stops retrying to watch key once
+ * maxRetries is exceeded. The returned observable completes
+ * when the watched key is deleted.
+ *
+ * @param key The database key to watch
+ * @param opts.retryDelay Number of milliseconds to wait
+ * between retrying the request. Defaults to 1000
+ * @param opts.maxRetries Maximum number of tries before
+ * giving up. Defaults to Infinity
+ * @param opts.ingoreNew Whether or not `new` notifications
+ * will be ignored. Defaults to false
+ * @param opts.ignoreDelete Whether or not "delete" notification
+ * will be ignored (and replaced by null)
+ * @param forwardDone: Whether or not the "done" message should be forwarded
+ */
+ watch(key: string, opts?: WatchOpts): Observable;
+ watch(
+ key: string,
+ opts?: WatchOpts & { ignoreDelete: true }
+ ): Observable;
+ watch(
+ key: string,
+ opts: WatchOpts,
+ _: { forwardDone: true }
+ ): Observable;
+ watch(
+ key: string,
+ opts: WatchOpts & { ignoreDelete: true },
+ _: { forwardDone: true }
+ ): Observable;
+ watch(
+ key: string,
+ opts: WatchOpts = {},
+ { forwardDone }: { forwardDone?: boolean } = {}
+ ): Observable {
+ return this.qsub(key, opts, { forwardDone } as any).pipe(
+ filter((reply) => reply.type !== 'done' || forwardDone === true),
+ filter((reply) => reply.type === 'done' || reply.key === key),
+ takeWhile((reply) => opts.ignoreDelete || reply.type !== 'del'),
+ filter((reply) => {
+ return !opts.ingoreNew || reply.type !== 'new';
+ }),
+ map((reply) => {
+ if (reply.type === 'del') {
+ return null;
+ }
+
+ if (reply.type === 'done') {
+ return reply;
+ }
+ return reply.data;
+ })
+ );
+ }
+
+ watchAll(
+ query: string,
+ opts?: RetryableOpts
+ ): Observable {
+ return new Observable((observer) => {
+ let values: T[] = [];
+ let keys: string[] = [];
+ let doneReceived = false;
+
+ const sub = this.request(
+ 'qsub',
+ { query },
+ { forwardDone: true }
+ ).subscribe({
+ next: (value) => {
+ if ((value as any).type === 'done') {
+ doneReceived = true;
+ observer.next(values);
+ return;
+ }
+
+ if (!doneReceived) {
+ values.push(value.data);
+ keys.push(value.key);
+ return;
+ }
+
+ const idx = keys.findIndex((k) => k === value.key);
+ switch (value.type) {
+ case 'new':
+ if (idx < 0) {
+ values.push(value.data);
+ keys.push(value.key);
+ } else {
+ /*
+ const existing = values[idx]._meta!;
+ const existingTs = existing.Modified || existing.Created;
+ const newTs = (value.data as Record)?._meta?.Modified || (value.data as Record)?._meta?.Created || 0;
+
+ console.log(`Comparing ${newTs} against ${existingTs}`);
+
+ if (newTs > existingTs) {
+ console.log(`New record is ${newTs - existingTs} seconds newer`);
+ values[idx] = value.data;
+ } else {
+ return;
+ }
+ */
+ values[idx] = value.data;
+ }
+ break;
+ case 'del':
+ if (idx >= 0) {
+ keys.splice(idx, 1);
+ values.splice(idx, 1);
+ }
+ break;
+ case 'upd':
+ if (idx >= 0) {
+ values[idx] = value.data;
+ }
+ break;
+ }
+
+ observer.next(values);
+ },
+ error: (err) => {
+ observer.error(err);
+ },
+ complete: () => {
+ observer.complete();
+ },
+ });
+
+ return () => {
+ sub.unsubscribe();
+ };
+ }).pipe(retryPipeline(opts));
+ }
+
+ /**
+ * Close the current websocket connection. A new subscription
+ * will _NOT_ trigger a reconnect.
+ */
+ close() {
+ if (!this.ws$) {
+ return;
+ }
+
+ this.ws$.complete();
+ this.ws$ = null;
+ }
+
+ request(
+ method: M,
+ attrs: Partial>,
+ { forwardDone }: { forwardDone?: boolean } = {}
+ ): Observable> {
+ return new Observable((observer) => {
+ const id = `${++uniqueRequestId}`;
+ if (!this.ws$) {
+ observer.error('No websocket connection');
+ return;
+ }
+
+ let shouldCancel = isCancellable(method);
+ let unsub: () => RequestMessage | null = () => {
+ if (shouldCancel) {
+ return {
+ id: id,
+ type: 'cancel',
+ };
+ }
+
+ return null;
+ };
+
+ const request: any = {
+ ...attrs,
+ id: id,
+ type: method,
+ };
+
+ let inspected: InspectedActiveRequest = {
+ type: method,
+ messagesReceived: 0,
+ observer: observer,
+ payload: request,
+ lastData: null,
+ lastKey: '',
+ };
+
+ if (isDevMode()) {
+ this.activeRequests.next({
+ ...this.inspectActiveRequests(),
+ [id]: inspected,
+ });
+ }
+
+ let stream$: Observable> = this.multiplex(
+ request,
+ unsub
+ );
+ if (isDevMode()) {
+ // in development mode we log all replys for the different
+ // methods. This also includes updates to subscriptions.
+ stream$ = stream$.pipe(
+ tap(
+ (msg) => { },
+ //msg => console.log(`[portapi] reply for ${method} ${id}: `, msg),
+ (err) => console.error(`[portapi] error in ${method} ${id}: `, err)
+ )
+ );
+ }
+
+ const subscription = stream$?.subscribe({
+ next: (data) => {
+ inspected.messagesReceived++;
+
+ // in all cases, an `error` message type
+ // terminates the data flow.
+ if (data.type === 'error') {
+ console.error(data.message, inspected);
+ shouldCancel = false;
+
+ observer.error(data.message);
+ return;
+ }
+
+ if (
+ method === 'create' ||
+ method === 'update' ||
+ method === 'insert' ||
+ method === 'delete'
+ ) {
+ // for data-manipulating methods success
+ // ends the stream.
+ if (data.type === 'success') {
+ observer.next();
+ observer.complete();
+ return;
+ }
+ }
+
+ if (method === 'query' || method === 'sub' || method === 'qsub') {
+ if (data.type === 'warning') {
+ console.warn(data.message);
+ return;
+ }
+
+ // query based methods send `done` once all
+ // results are sent at least once.
+ if (data.type === 'done') {
+ if (method === 'query') {
+ // done ends the query but does not end sub or qsub
+ shouldCancel = false;
+ observer.complete();
+ return;
+ }
+
+ if (!!forwardDone) {
+ // A done message in qsub does not actually represent
+ // a DataReply but we still want to forward that.
+ observer.next(data as any);
+ }
+ return;
+ }
+ }
+
+ if (!isDataReply(data)) {
+ console.error(
+ `Received unexpected message type ${data.type} in a ${method} operation`
+ );
+ return;
+ }
+
+ inspected.lastData = data.data;
+ inspected.lastKey = data.key;
+
+ observer.next(data);
+
+ // for a `get` method the first `ok` message
+ // also marks the end of the stream.
+ if (method === 'get' && data.type === 'ok') {
+ shouldCancel = false;
+ observer.complete();
+ }
+ },
+ error: (err) => {
+ console.error(err, attrs);
+ observer.error(err);
+ },
+ complete: () => {
+ observer.complete();
+ },
+ });
+
+ if (isDevMode()) {
+ // make sure we remove the "active" request when the subscription
+ // goes down
+ subscription.add(() => {
+ const active = this.inspectActiveRequests();
+ delete active[request.id];
+ this.activeRequests.next(active);
+ });
+ }
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ });
+ }
+
+ private multiplex(
+ req: RequestMessage,
+ cancel: (() => RequestMessage | null) | null
+ ): Observable {
+ return new Observable((observer) => {
+ if (this.connectedSubject.getValue()) {
+ // Try to directly send the request to the backend
+ this._streams$.set(req.id, observer);
+ this.ws$!.next(req);
+ } else {
+ // in case of an error we just add the request as
+ // "pending" and wait for the connection to be
+ // established.
+ console.warn(
+ `Failed to send request ${req.id}:${req.type}, marking as pending ...`
+ );
+ this._pendingCalls$.set(req.id, {
+ request: req,
+ observer: observer,
+ });
+ }
+
+ return () => {
+ // Try to cancel the request but ingore
+ // any errors here.
+ try {
+ if (cancel !== null) {
+ const cancelMsg = cancel();
+ if (!!cancelMsg) {
+ this.ws$!.next(cancelMsg);
+ }
+ }
+ } catch (err) { }
+
+ this._pendingCalls$.delete(req.id);
+ this._streams$.delete(req.id);
+ };
+ });
+ }
+
+ /**
+ * Inject a message into a PortAPI stream.
+ *
+ * @param id The request ID to inject msg into.
+ * @param msg The message to inject.
+ */
+ _injectMessage(id: string, msg: DataReply) {
+ // we are using runTask here so change-detection is
+ // triggered as needed
+ this.ngZone.runTask(() => {
+ const req = this.activeRequests.getValue()[id];
+ if (!req) {
+ return;
+ }
+
+ req.observer.next(msg as DataReply);
+ });
+ }
+
+ /**
+ * Injects a 'ok' type message
+ *
+ * @param id The ID of the request to inject into
+ * @param data The data blob to inject
+ * @param key [optional] The key of the entry to inject
+ */
+ _injectData(id: string, data: any, key: string = '') {
+ this._injectMessage(id, { type: 'ok', data: data, key, id: id });
+ }
+
+ /**
+ * Patches the last message received on id by deeply merging
+ * data and re-injects that message.
+ *
+ * @param id The ID of the request
+ * @param data The patch to apply and reinject
+ */
+ _patchLast(id: string, data: any) {
+ const req = this.activeRequests.getValue()[id];
+ if (!req || !req.lastData) {
+ return;
+ }
+
+ const newPayload = mergeDeep({}, req.lastData, data);
+ this._injectData(id, newPayload, req.lastKey);
+ }
+
+ private stripMeta(obj: T): T {
+ let copy = {
+ ...obj,
+ _meta: undefined,
+ };
+ return copy;
+ }
+
+ /**
+ * Creates a new websocket subject and configures appropriate serializer
+ * and deserializer functions for PortAPI.
+ *
+ * @private
+ */
+ private createWebsocket(): WebSocketSubject {
+ return this.websocketFactory.createConnection<
+ ReplyMessage | RequestMessage
+ >({
+ url: this.wsEndpoint,
+ serializer: (msg) => {
+ try {
+ return serializeMessage(msg);
+ } catch (err) {
+ console.error('serialize message', err);
+ return {
+ type: 'error',
+ };
+ }
+ },
+ // deserializeMessage also supports RequestMessage so cast as any
+ deserializer: ((msg: any) => {
+ try {
+ const res = deserializeMessage(msg);
+ return res;
+ } catch (err) {
+ console.error('deserialize message', err);
+ return {
+ type: 'error',
+ };
+ }
+ }),
+ binaryType: 'arraybuffer',
+ openObserver: {
+ next: () => {
+ console.log('[portapi] connection to portmaster established');
+ this.connectedSubject.next(true);
+ this._flushPendingMethods();
+ },
+ },
+ closeObserver: {
+ next: () => {
+ console.log('[portapi] connection to portmaster closed');
+ this.connectedSubject.next(false);
+ },
+ },
+ closingObserver: {
+ next: () => {
+ console.log('[portapi] connection to portmaster closing');
+ },
+ },
+ });
+ }
+}
+
+// Counts the number of "truthy" datafields in obj.
+function countTruthyDataFields(obj: { [key: string]: any }): number {
+ let count = 0;
+ Object.keys(obj).forEach((key) => {
+ let value = obj[key];
+ if (!!value) {
+ count++;
+ }
+ });
+ return count;
+}
+
+function isObject(item: any): item is Object {
+ return item && typeof item === 'object' && !Array.isArray(item);
+}
+
+export function mergeDeep(target: any, ...sources: any): any {
+ if (!sources.length) return target;
+ const source = sources.shift();
+
+ if (isObject(target) && isObject(source)) {
+ for (const key in source) {
+ if (isObject(source[key])) {
+ if (!target[key]) Object.assign(target, { [key]: {} });
+ mergeDeep(target[key], source[key]);
+ } else {
+ Object.assign(target, { [key]: source[key] });
+ }
+ }
+ }
+
+ return mergeDeep(target, ...sources);
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts
new file mode 100644
index 00000000..349c7b9f
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts
@@ -0,0 +1,453 @@
+import { iif, MonoTypeOperatorFunction, of, Subscriber, throwError } from 'rxjs';
+import { concatMap, delay, retryWhen } from 'rxjs/operators';
+
+/**
+* ReplyType contains all possible message types of a reply.
+*/
+export type ReplyType = 'ok'
+ | 'upd'
+ | 'new'
+ | 'del'
+ | 'success'
+ | 'error'
+ | 'warning'
+ | 'done';
+
+/**
+* RequestType contains all possible message types of a request.
+*/
+export type RequestType = 'get'
+ | 'query'
+ | 'sub'
+ | 'qsub'
+ | 'create'
+ | 'update'
+ | 'insert'
+ | 'delete'
+ | 'cancel';
+
+// RecordMeta describes the meta-data object that is part of
+// every API resource.
+export interface RecordMeta {
+ // Created hold a unix-epoch timestamp when the record has been
+ // created.
+ Created: number;
+ // Deleted hold a unix-epoch timestamp when the record has been
+ // deleted.
+ Deleted: number;
+ // Expires hold a unix-epoch timestamp when the record has been
+ // expires.
+ Expires: number;
+ // Modified hold a unix-epoch timestamp when the record has been
+ // modified last.
+ Modified: number;
+ // Key holds the database record key.
+ Key: string;
+}
+
+export interface Process extends Record {
+ Name: string;
+ UserID: number;
+ UserName: string;
+ UserHome: string;
+ Pid: number;
+ Pgid: number;
+ CreatedAt: number;
+ ParentPid: number;
+ ParentCreatedAt: number;
+ Path: string;
+ ExecName: string;
+ Cwd: string;
+ CmdLine: string;
+ FirstArg: string;
+ Env: {
+ [key: string]: string
+ } | null;
+ Tags: {
+ Key: string;
+ Value: string;
+ }[] | null;
+ MatchingPath: string;
+ PrimaryProfileID: string;
+ FirstSeen: number;
+ LastSeen: number;
+ Error: string;
+ ExecHashes: {
+ [key: string]: string
+ } | null;
+}
+
+// Record describes the base record structure of all API resources.
+export interface Record {
+ _meta?: RecordMeta;
+}
+
+/**
+* All possible MessageType that are available in PortAPI.
+*/
+export type MessageType = RequestType | ReplyType;
+
+/**
+* BaseMessage describes the base message type that is exchanged
+* via PortAPI.
+*/
+export interface BaseMessage {
+ // ID of the request. Used to correlated (multiplex) requests and
+ // responses across a single websocket connection.
+ id: string;
+ // Type is the request/response message type.
+ type: M;
+}
+
+/**
+* DoneReply marks the end of a PortAPI stream.
+*/
+export interface DoneReply extends BaseMessage<'done'> { }
+
+/**
+* DataReply is either sent once as a result on a `get` request or
+* is sent multiple times in the course of a PortAPI stream.
+*/
+export interface DataReply extends BaseMessage<'ok' | 'upd' | 'new' | 'del'> {
+ // Key is the database key including the database prefix.
+ key: string;
+ // Data is the actual data of the entry.
+ data: T;
+}
+
+/**
+ * Returns true if d is a DataReply message type.
+ *
+ * @param d The reply message to check
+ */
+export function isDataReply(d: ReplyMessage): d is DataReply {
+ return d.type === 'ok'
+ || d.type === 'upd'
+ || d.type === 'new'
+ || d.type === 'del';
+ //|| d.type === 'done'; // done is actually not correct
+}
+
+/**
+* SuccessReply is used to mark an operation as successfully. It does not carry any
+* data. Think of it as a "201 No Content" in HTTP.
+*/
+export interface SuccessReply extends BaseMessage<'success'> { }
+
+/**
+* ErrorReply describes an error that happened while processing a
+* request. Note that an `error` type message may be sent for single
+* and response-stream requests. In case of a stream the `error` type
+* message marks the end of the stream. See WarningReply for a simple
+* warning message that can be transmitted via PortAPI.
+*/
+export interface ErrorReply extends BaseMessage<'error'> {
+ // Message is the error message from the backend.
+ message: string;
+}
+
+/**
+* WarningReply contains a warning message that describes an error
+* condition encountered when processing a single entitiy of a
+* response stream. In contrast to `error` type messages, a `warning`
+* can only occure during data streams and does not end the stream.
+*/
+export interface WarningReply extends BaseMessage<'warning'> {
+ // Message describes the warning/error condition the backend
+ // encountered.
+ message: string;
+}
+
+/**
+* QueryRequest defines the payload for `query`, `sub` and `qsub` message
+* types. The result of a query request is always a stream of responses.
+* See ErrorReply, WarningReply and DoneReply for more information.
+*/
+export interface QueryRequest extends BaseMessage<'query' | 'sub' | 'qsub'> {
+ // Query is the query for the database.
+ query: string;
+}
+
+/**
+* KeyRequests defines the payload for a `get` or `delete` request. Those
+* message type only carry the key of the database entry to delete. Note that
+* `delete` can only return a `success` or `error` type message while `get` will
+* receive a `ok` or `error` type message.
+*/
+export interface KeyRequest extends BaseMessage<'delete' | 'get'> {
+ // Key is the database entry key.
+ key: string;
+}
+
+
+/**
+* DataRequest is used during create, insert or update operations.
+* TODO(ppacher): check what's the difference between create and insert,
+* both seem to error when trying to create a new entry.
+*/
+export interface DataRequest extends BaseMessage<'update' | 'create' | 'insert'> {
+ // Key is the database entry key.
+ key: string;
+ // Data is the data to store.
+ data: T;
+}
+
+/**
+ * CancelRequest can be sent on stream operations to early-abort the request.
+ */
+export interface CancelRequest extends BaseMessage<'cancel'> { }
+
+/**
+* ReplyMessage is a union of all reply message types.
+*/
+export type ReplyMessage = DataReply
+ | DoneReply
+ | SuccessReply
+ | WarningReply
+ | ErrorReply;
+
+/**
+* RequestMessage is a union of all request message types.
+*/
+export type RequestMessage = QueryRequest
+ | KeyRequest
+ | DataRequest
+ | CancelRequest;
+
+/**
+* Requestable can be used to accept only properties that match
+* the request message type M.
+*/
+export type Requestable = RequestMessage & { type: M };
+
+/**
+ * Returns true if m is a cancellable message type.
+ *
+ * @param m The message type to check.
+ */
+export function isCancellable(m: MessageType): boolean {
+ switch (m) {
+ case 'qsub':
+ case 'sub':
+ return true;
+ default:
+ return false;
+ }
+}
+
+/**
+ * Reflects a currently in-flight PortAPI request. Used to
+ * intercept and mangle with responses.
+ */
+export interface InspectedActiveRequest {
+ // The type of request.
+ type: RequestType;
+ // The actual request payload.
+ // @todo(ppacher): typings
+ payload: any;
+ // The request observer. Use to inject data
+ // or complete/error the subscriber. Use with
+ // care!
+ observer: Subscriber>;
+ // Counter for the number of messages received
+ // for this request.
+ messagesReceived: number;
+ // The last data received on the request
+ lastData: any;
+ // The last key received on the request
+ lastKey: string;
+}
+
+export interface RetryableOpts {
+ // A delay in milliseconds before retrying an operation.
+ retryDelay?: number;
+ // The maximum number of retries.
+ maxRetries?: number;
+}
+
+export interface ProfileImportResult extends ImportResult {
+ replacesProfiles: string[];
+}
+
+export interface ImportResult {
+ restartRequired: boolean;
+ replacesExisting: boolean;
+ containsUnknown: boolean;
+}
+
+/**
+ * Returns a RxJS operator function that implements a retry pipeline
+ * with a configurable retry delay and an optional maximum retry count.
+ * If maxRetries is reached the last error captured is thrown.
+ *
+ * @param opts Configuration options for the retryPipeline.
+ * see {@type RetryableOpts} for more information.
+ */
+export function retryPipeline({ retryDelay, maxRetries }: RetryableOpts = {}): MonoTypeOperatorFunction {
+ return retryWhen(errors => errors.pipe(
+ // use concatMap to keep the errors in order and make sure
+ // they don't execute in parallel.
+ concatMap((e, i) =>
+ iif(
+ // conditional observable seletion, throwError if i > maxRetries
+ // or a retryDelay otherwise
+ () => i > (maxRetries || Infinity),
+ throwError(() => e),
+ of(e).pipe(delay(retryDelay || 1000))
+ )
+ )
+ ))
+}
+
+export interface WatchOpts extends RetryableOpts {
+ // Whether or not `new` updates should be filtered
+ // or let through. See {@method PortAPI.watch} for
+ // more information.
+ ingoreNew?: boolean;
+
+ ignoreDelete?: boolean;
+}
+
+
+/**
+* Serializes a request or reply message into it's wire format.
+*
+* @param msg The request or reply messsage to serialize
+*/
+export function serializeMessage(msg: RequestMessage | ReplyMessage): any {
+ if (msg === undefined) {
+ return undefined;
+ }
+
+ let blob = `${msg.id}|${msg.type}`;
+
+ switch (msg.type) {
+ case 'done': // reply
+ case 'success': // reply
+ case 'cancel': // request
+ break;
+
+ case 'error': // reply
+ case 'warning': // reply
+ blob += `|${msg.message}`
+ break;
+
+ case 'ok': // reply
+ case 'upd': // reply
+ case 'new': // reply
+ case 'insert': // request
+ case 'update': // request
+ case 'create': // request
+ blob += `|${msg.key}|J${JSON.stringify(msg.data)}`
+ break;
+
+
+ case 'del': // reply
+ case 'get': // request
+ case 'delete': // request
+ blob += `|${msg.key}`
+ break;
+
+ case 'query': // request
+ case 'sub': // request
+ case 'qsub': // request
+ blob += `|query ${msg.query}`
+ break;
+
+ default:
+ // We need (msg as any) here because typescript knows that we covered
+ // all possible values above and that .type can never be something else.
+ // Still, we want to guard against unexpected portmaster message
+ // types.
+ console.error(`Unknown message type ${(msg as any).type}`);
+ }
+
+ return blob;
+}
+
+/**
+* Deserializes (loads) a PortAPI message from a WebSocket message event.
+*
+* @param event The WebSocket MessageEvent to parse.
+*/
+export function deserializeMessage(event: MessageEvent): RequestMessage | ReplyMessage {
+ let data: string;
+
+ if (typeof event.data !== 'string') {
+ data = new TextDecoder("utf-8").decode(event.data)
+ } else {
+ data = event.data;
+ }
+
+ const parts = data.split("|");
+
+ if (parts.length < 2) {
+ throw new Error(`invalid number of message parts, expected 3-4 but got ${parts.length}`);
+ }
+
+ const id = parts[0];
+ const type = parts[1] as MessageType;
+
+ var msg: Partial = {
+ id,
+ type,
+ }
+
+ if (parts.length > 4) {
+ parts[3] = parts.slice(3).join('|')
+ }
+
+ switch (msg.type) {
+ case 'done': // reply
+ case 'success': // reply
+ case 'cancel': // request
+ break;
+
+ case 'error': // reply
+ case 'warning': // reply
+ msg.message = parts[2];
+ break;
+
+ case 'ok': // reply
+ case 'upd': // reply
+ case 'new': // reply
+ case 'insert': // request
+ case 'update': // request
+ case 'create': // request
+ msg.key = parts[2];
+ try {
+ if (parts[3][0] === 'J') {
+ msg.data = JSON.parse(parts[3].slice(1));
+ } else {
+ msg.data = parts[3];
+ }
+ } catch (e) {
+ console.log(e, data)
+ }
+ break;
+
+ case 'del': // reply
+ case 'get': // request
+ case 'delete': // request
+ msg.key = parts[2];
+ break;
+
+ case 'query': // request
+ case 'sub': // request
+ case 'qsub': // request
+ msg.query = parts[2];
+ if (msg.query.startsWith("query ")) {
+ msg.query = msg.query.slice(6);
+ }
+ break;
+
+ default:
+ // We need (msg as any) here because typescript knows that we covered
+ // all possible values above and that .type can never be something else.
+ // Still, we want to guard against unexpected portmaster message
+ // types.
+ console.error(`Unknown message type ${(msg as any).type}`);
+ }
+
+ return msg as (ReplyMessage | RequestMessage); // it's not partitial anymore
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts
new file mode 100644
index 00000000..fc0a6047
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts
@@ -0,0 +1,171 @@
+import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
+import { Inject, Injectable } from "@angular/core";
+import { BehaviorSubject, Observable, of } from "rxjs";
+import { filter, map, share, switchMap } from "rxjs/operators";
+import { FeatureID } from "./features";
+import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service';
+import { Feature, Pin, SPNStatus, UserProfile } from "./spn.types";
+
+@Injectable({ providedIn: 'root' })
+export class SPNService {
+
+ /** Emits the SPN status whenever it changes */
+ status$: Observable;
+
+ profile$ = this.watchProfile()
+ .pipe(
+ share({ connector: () => new BehaviorSubject(undefined) }),
+ filter(val => val !== undefined)
+ ) as Observable;
+
+ private pins$: Observable;
+
+ constructor(
+ private portapi: PortapiService,
+ private http: HttpClient,
+ @Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
+ ) {
+ this.status$ = this.portapi.watch('runtime:spn/status', { ignoreDelete: true })
+ .pipe(
+ share({ connector: () => new BehaviorSubject(null) }),
+ filter(val => val !== null),
+ )
+
+ this.pins$ = this.status$
+ .pipe(
+ switchMap(status => {
+ if (status.Status !== "disabled") {
+ return this.portapi.watchAll("map:main/", { retryDelay: 50000 })
+ }
+
+ return of([] as Pin[]);
+ }),
+ share({ connector: () => new BehaviorSubject(undefined) }),
+ filter(val => val !== undefined)
+ ) as Observable;
+ }
+
+ /**
+ * Watches all pins of the "main" SPN map.
+ */
+ watchPins(): Observable {
+ return this.pins$;
+ }
+
+ /**
+ * Encodes a unicode string to base64.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/btoa
+ * and https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
+ */
+ b64EncodeUnicode(str: string): string {
+ return window.btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
+ return String.fromCharCode(parseInt(p1, 16))
+ }))
+ }
+
+ /**
+ * Logs into the SPN user account
+ */
+ login({ username, password }: { username: string, password: string }): Observable> {
+ return this.http.post(`${this.httpAPI}/v1/spn/account/login`, undefined, {
+ headers: {
+ Authorization: `Basic ${this.b64EncodeUnicode(username + ':' + password)}`
+ },
+ responseType: 'text',
+ observe: 'response'
+ });
+ }
+
+ /**
+ * Log out of the SPN user account
+ *
+ * @param purge Whether or not the portmaster should keep user/device information for the next login
+ */
+ logout(purge = false): Observable> {
+ let params = new HttpParams();
+ if (!!purge) {
+ params = params.set("purge", "true")
+ }
+ return this.http.delete(`${this.httpAPI}/v1/spn/account/logout`, {
+ params,
+ responseType: 'text',
+ observe: 'response'
+ })
+ }
+
+ watchEnabledFeatures(): Observable<(Feature & { enabled: boolean })[]> {
+ return this.profile$
+ .pipe(
+ switchMap(profile => {
+ return this.loadFeaturePackages()
+ .pipe(
+ map(features => {
+ return features.map(feature => {
+ // console.log(feature, profile?.current_plan?.feature_ids)
+ return {
+ ...feature,
+ enabled: feature.RequiredFeatureID === FeatureID.None || profile?.current_plan?.feature_ids?.includes(feature.RequiredFeatureID) || false,
+ }
+ })
+ })
+ )
+ })
+ );
+ }
+
+ /** Returns a list of all feature packages */
+ loadFeaturePackages(): Observable {
+ return this.http.get<{ Features: Feature[] }>(`${this.httpAPI}/v1/account/features`)
+ .pipe(
+ map(response => response.Features.map(feature => {
+ return {
+ ...feature,
+ IconURL: `${this.httpAPI}/v1/account/features/${feature.ID}/icon`,
+ }
+ }))
+ );
+ }
+
+ /**
+ * Returns the current SPN user profile.
+ *
+ * @param refresh Whether or not the user profile should be refreshed from the ticket agent
+ * @returns
+ */
+ userProfile(refresh = false): Observable {
+ let params = new HttpParams();
+ if (!!refresh) {
+ params = params.set("refresh", true)
+ }
+ return this.http.get(`${this.httpAPI}/v1/spn/account/user/profile`, {
+ params
+ });
+ }
+
+ /**
+ * Watches the user profile. It will emit null if there is no profile available yet.
+ */
+ watchProfile(): Observable {
+ let hasSent = false;
+ return this.portapi.watch('core:spn/account/user', { ignoreDelete: true }, { forwardDone: true })
+ .pipe(
+ filter(result => {
+ if ('type' in result && result.type === 'done') {
+ if (hasSent) {
+ return false;
+ }
+ }
+
+ return true
+ }),
+ map(result => {
+ hasSent = true;
+ if ('type' in result) {
+ return null;
+ }
+
+ return result;
+ })
+ );
+ }
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts
new file mode 100644
index 00000000..b2e7caaf
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts
@@ -0,0 +1,104 @@
+import { FeatureID } from './features';
+import { CountryInfo, GeoCoordinates, IntelEntity } from './network.types';
+import { Record } from './portapi.types';
+
+export interface SPNStatus extends Record {
+ Status: 'failed' | 'disabled' | 'connecting' | 'connected';
+ HomeHubID: string;
+ HomeHubName: string;
+ ConnectedIP: string;
+ ConnectedTransport: string;
+ ConnectedCountry: CountryInfo | null;
+ ConnectedSince: string | null;
+}
+
+export interface Pin extends Record {
+ ID: string;
+ Name: string;
+ FirstSeen: string;
+ EntityV4?: IntelEntity | null;
+ EntityV6?: IntelEntity | null;
+ States: string[];
+ SessionActive: boolean;
+ HopDistance: number;
+ ConnectedTo: {
+ [key: string]: Lane,
+ };
+ Route: string[] | null;
+ VerifiedOwner: string;
+}
+
+export interface Lane {
+ HubID: string;
+ Capacity: number;
+ Latency: number;
+}
+
+export function getPinCoords(p: Pin): GeoCoordinates | null {
+ if (p.EntityV4 && p.EntityV4.Coordinates) {
+ return p.EntityV4.Coordinates;
+ }
+ return p.EntityV6?.Coordinates || null;
+}
+
+export interface Device {
+ name: string;
+ id: string;
+}
+
+export interface Subscription {
+ ends_at: string;
+ state: 'manual' | 'active' | 'cancelled';
+ next_billing_date: string;
+ payment_provider: string;
+}
+
+export interface Plan {
+ name: string;
+ amount: number;
+ months: number;
+ renewable: boolean;
+ feature_ids: FeatureID[];
+}
+
+export interface View {
+ Message: string;
+ ShowAccountData: boolean;
+ ShowAccountButton: boolean;
+ ShowLoginButton: boolean;
+ ShowRefreshButton: boolean;
+ ShowLogoutButton: boolean;
+}
+
+export interface UserProfile extends Record {
+ username: string;
+ state: string;
+ balance: number;
+ device: Device | null;
+ subscription: Subscription | null;
+ current_plan: Plan | null;
+ next_plan: Plan | null;
+ view: View | null;
+ LastNotifiedOfEnd?: string;
+ LoggedInAt?: string;
+}
+
+export interface Package {
+ Name: string;
+ HexColor: string;
+}
+
+export interface Feature {
+ ID: string;
+ Name: string;
+ ConfigKey: string;
+ ConfigScope: string;
+ RequiredFeatureID: FeatureID;
+ InPackage: Package | null;
+ Comment: string;
+ Beta?: boolean;
+ ComingSoon?: boolean;
+
+ // does not come from the PM API but is set by SPNService
+ IconURL: string;
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts
new file mode 100644
index 00000000..80b97573
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts
@@ -0,0 +1,13 @@
+
+export function deepClone(o?: T | null): T {
+ if (o === null || o === undefined) {
+ return null as any as T;
+ }
+
+ let _out: T = (Array.isArray(o) ? [] : {}) as T;
+ for (let _key in (o as T)) {
+ let v = o[_key];
+ _out[_key] = (typeof v === "object") ? deepClone(v) : v;
+ }
+ return _out as T;
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts
new file mode 100644
index 00000000..c42efa8d
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts
@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core';
+import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
+
+@Injectable()
+export class WebsocketService {
+ constructor() { }
+
+ /**
+ * createConnection creates a new websocket connection using opts.
+ *
+ * @param opts Options for the websocket connection.
+ */
+ createConnection(opts: WebSocketSubjectConfig): WebSocketSubject {
+ return webSocket(opts);
+ }
+}
+
diff --git a/desktop/angular/projects/safing/portmaster-api/src/public-api.ts b/desktop/angular/projects/safing/portmaster-api/src/public-api.ts
new file mode 100644
index 00000000..9097761e
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/public-api.ts
@@ -0,0 +1,22 @@
+/*
+ * Public API Surface of portmaster-api
+ */
+
+export * from './lib/app-profile.service';
+export * from './lib/app-profile.types';
+export * from './lib/config.service';
+export * from './lib/config.types';
+export * from './lib/core.types';
+export * from './lib/debug-api.service';
+export * from './lib/features';
+export * from './lib/meta-api.service';
+export * from './lib/module';
+export * from './lib/netquery.service';
+export * from './lib/network.types';
+export * from './lib/portapi.service';
+export * from './lib/portapi.types';
+export * from './lib/spn.service';
+export * from './lib/spn.types';
+export * from './lib/utils';
+export * from './lib/websocket.service';
+
diff --git a/desktop/angular/projects/safing/portmaster-api/src/test.ts b/desktop/angular/projects/safing/portmaster-api/src/test.ts
new file mode 100644
index 00000000..43808367
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/src/test.ts
@@ -0,0 +1,15 @@
+// This file is required by karma.conf.js and loads recursively all the .spec and framework files
+
+import 'zone.js';
+import 'zone.js/testing';
+import { getTestBed } from '@angular/core/testing';
+import {
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting
+} from '@angular/platform-browser-dynamic/testing';
+
+// First, initialize the Angular testing environment.
+getTestBed().initTestEnvironment(
+ BrowserDynamicTestingModule,
+ platformBrowserDynamicTesting(),
+);
diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json
new file mode 100644
index 00000000..c9f14589
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json
@@ -0,0 +1,16 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../out-tsc/lib",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": [
+ "src/test.ts",
+ "testing/**/*",
+ "**/*.spec.ts"
+ ]
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json
new file mode 100644
index 00000000..71b135f6
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json
@@ -0,0 +1,7 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.lib.json",
+ "compilerOptions": {
+ "declarationMap": false
+ },
+}
diff --git a/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json b/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json
new file mode 100644
index 00000000..258250d2
--- /dev/null
+++ b/desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json
@@ -0,0 +1,18 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../out-tsc/spec",
+ "types": [
+ "jasmine"
+ ]
+ },
+ "files": [
+ "testing/**/*.ts"
+ ],
+ "include": [
+ "testing/**/*.ts",
+ "**/*.spec.ts",
+ "**/*.d.ts"
+ ]
+}
diff --git a/desktop/angular/projects/safing/ui/.eslintrc.json b/desktop/angular/projects/safing/ui/.eslintrc.json
new file mode 100644
index 00000000..91e1f496
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/.eslintrc.json
@@ -0,0 +1,44 @@
+{
+ "extends": "../../../.eslintrc.json",
+ "ignorePatterns": [
+ "!**/*"
+ ],
+ "overrides": [
+ {
+ "files": [
+ "*.ts"
+ ],
+ "parserOptions": {
+ "project": [
+ "projects/safing/ui/tsconfig.lib.json",
+ "projects/safing/ui/tsconfig.spec.json"
+ ],
+ "createDefaultProgram": true
+ },
+ "rules": {
+ "@angular-eslint/directive-selector": [
+ "error",
+ {
+ "type": "attribute",
+ "prefix": "sfng",
+ "style": "camelCase"
+ }
+ ],
+ "@angular-eslint/component-selector": [
+ "error",
+ {
+ "type": "element",
+ "prefix": "sfng",
+ "style": "kebab-case"
+ }
+ ]
+ }
+ },
+ {
+ "files": [
+ "*.html"
+ ],
+ "rules": {}
+ }
+ ]
+}
diff --git a/desktop/angular/projects/safing/ui/README.md b/desktop/angular/projects/safing/ui/README.md
new file mode 100644
index 00000000..cf11e371
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/README.md
@@ -0,0 +1,24 @@
+# Ui
+
+This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.2.0.
+
+## Code scaffolding
+
+Run `ng generate component component-name --project ui` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ui`.
+> Note: Don't forget to add `--project ui` or else it will be added to the default project in your `angular.json` file.
+
+## Build
+
+Run `ng build ui` to build the project. The build artifacts will be stored in the `dist/` directory.
+
+## Publishing
+
+After building your library with `ng build ui`, go to the dist folder `cd dist/ui` and run `npm publish`.
+
+## Running unit tests
+
+Run `ng test ui` to execute the unit tests via [Karma](https://karma-runner.github.io).
+
+## Further help
+
+To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
diff --git a/desktop/angular/projects/safing/ui/karma.conf.js b/desktop/angular/projects/safing/ui/karma.conf.js
new file mode 100644
index 00000000..8975477b
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/karma.conf.js
@@ -0,0 +1,44 @@
+// Karma configuration file, see link for more information
+// https://karma-runner.github.io/1.0/config/configuration-file.html
+
+module.exports = function (config) {
+ config.set({
+ basePath: '',
+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
+ plugins: [
+ require('karma-jasmine'),
+ require('karma-chrome-launcher'),
+ require('karma-jasmine-html-reporter'),
+ require('karma-coverage'),
+ require('@angular-devkit/build-angular/plugins/karma')
+ ],
+ client: {
+ jasmine: {
+ // you can add configuration options for Jasmine here
+ // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
+ // for example, you can disable the random execution with `random: false`
+ // or set a specific seed with `seed: 4321`
+ },
+ clearContext: false // leave Jasmine Spec Runner output visible in browser
+ },
+ jasmineHtmlReporter: {
+ suppressAll: true // removes the duplicated traces
+ },
+ coverageReporter: {
+ dir: require('path').join(__dirname, '../../../coverage/safing/ui'),
+ subdir: '.',
+ reporters: [
+ { type: 'html' },
+ { type: 'text-summary' }
+ ]
+ },
+ reporters: ['progress', 'kjhtml'],
+ port: 9876,
+ colors: true,
+ logLevel: config.LOG_INFO,
+ autoWatch: true,
+ browsers: ['Chrome'],
+ singleRun: false,
+ restartOnFileChange: true
+ });
+};
diff --git a/desktop/angular/projects/safing/ui/ng-package.json b/desktop/angular/projects/safing/ui/ng-package.json
new file mode 100644
index 00000000..4a890c44
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/ng-package.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
+ "dest": "../../../dist-lib/safing/ui",
+ "lib": {
+ "entryFile": "src/public-api.ts"
+ },
+ "assets": [
+ "theming.scss",
+ "**/_*.scss"
+ ]
+}
diff --git a/desktop/angular/projects/safing/ui/package.json b/desktop/angular/projects/safing/ui/package.json
new file mode 100644
index 00000000..52fa541a
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@safing/ui",
+ "version": "0.0.1",
+ "peerDependencies": {
+ "@angular/common": "~12.2.0",
+ "@angular/core": "~12.2.0",
+ "@angular/cdk": "~12.2.0"
+ },
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "exports": {
+ "./theming": {
+ "sass": "./theming.scss"
+ }
+ }
+}
diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html
new file mode 100644
index 00000000..6dbc7430
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html
@@ -0,0 +1 @@
+
diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts
new file mode 100644
index 00000000..3c152842
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts
@@ -0,0 +1,116 @@
+import { coerceBooleanProperty } from '@angular/cdk/coercion';
+import { ChangeDetectionStrategy, Component, Input, OnDestroy, TemplateRef } from '@angular/core';
+import { Subscription } from 'rxjs';
+import { SfngAccordionComponent } from './accordion';
+
+@Component({
+ selector: 'sfng-accordion-group',
+ templateUrl: './accordion-group.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SfngAccordionGroupComponent implements OnDestroy {
+ /** @private Currently registered accordion components */
+ accordions: SfngAccordionComponent[] = [];
+
+ /**
+ * A template-ref to render as the header for each accordion-component.
+ * Receives the accordion data as an $implicit context.
+ */
+ @Input()
+ set headerTemplate(v: TemplateRef | null) {
+ this._headerTemplate = v;
+
+ if (!!this.accordions.length) {
+ this.accordions.forEach(a => {
+ a.headerTemplate = v;
+ a.cdr.markForCheck();
+ })
+ }
+ }
+ get headerTemplate() { return this._headerTemplate }
+ private _headerTemplate: TemplateRef | null = null;
+
+ /** Whether or not one or more components can be expanded. */
+ @Input()
+ set singleMode(v: any) {
+ this._singleMode = coerceBooleanProperty(v);
+ }
+ get singleMode() { return this._singleMode }
+ private _singleMode = false;
+
+ /** Whether or not the accordion is disabled and does not allow expanding */
+ @Input()
+ set disabled(v: any) {
+ this._disabled = coerceBooleanProperty(v);
+ if (this._disabled) {
+ this.accordions.forEach(a => a.active = false);
+ }
+ }
+ get disabled(): boolean { return this._disabled; }
+ private _disabled = false;
+
+ /** A list of subscriptions to the activeChange output of the registered accordion-components */
+ private subscriptions: Subscription[] = [];
+
+ /**
+ * Registeres an accordion component to be handled together with this
+ * accordion group.
+ *
+ * @param a The accordion component to register
+ */
+ register(a: SfngAccordionComponent) {
+ this.accordions.push(a);
+
+ // Tell the accordion-component about the default header-template.
+ if (!a.headerTemplate) {
+ a.headerTemplate = this.headerTemplate;
+ }
+
+ // Subscribe to the activeChange output of the registered
+ // accordion and call toggle() for each event emitted.
+ this.subscriptions.push(a.activeChange.subscribe(() => {
+ if (this.disabled) {
+ return;
+ }
+
+ this.toggle(a);
+ }))
+ }
+
+ /**
+ * Unregisters a accordion component
+ *
+ * @param a The accordion component to unregister
+ */
+ unregister(a: SfngAccordionComponent) {
+ const index = this.accordions.indexOf(a);
+ if (index === -1) return;
+
+ const subscription = this.subscriptions[index];
+
+ subscription.unsubscribe();
+ this.accordions = this.accordions.splice(index, 1);
+ this.subscriptions = this.subscriptions.splice(index, 1);
+ }
+
+ ngOnDestroy() {
+ this.subscriptions.forEach(s => s.unsubscribe());
+ this.subscriptions = [];
+ this.accordions = [];
+ }
+
+ /**
+ * Expand an accordion component and collaps all others if
+ * single-mode is selected.
+ *
+ * @param a The accordion component to toggle.
+ */
+ private toggle(a: SfngAccordionComponent) {
+ if (!a.active && this._singleMode) {
+ this.accordions?.forEach(a => a.active = false);
+ }
+
+ a.active = !a.active;
+ }
+
+}
diff --git a/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html
new file mode 100644
index 00000000..4d47b842
--- /dev/null
+++ b/desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html
@@ -0,0 +1,10 @@
+