Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View File

@@ -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<string, Observable<AppProfile>>();
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<AppProfile>;
/**
* Load an application profile.
*
* @param source The source of the profile
* @param id The ID of the profile
*/
getAppProfile(source: string, id: string): Observable<AppProfile>;
getAppProfile(
sourceOrSourceAndID: string,
id?: string
): Observable<AppProfile> {
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<AppProfile> {
return this.portapi.get(key);
}
/**
* Loads the global-configuration profile.
*/
globalConfig(): Observable<FlatConfigObject> {
return this.getAppProfile('special', 'global-config').pipe(
map((profile) => flattenProfileConfig(profile.Config))
);
}
/** Returns all possible process tags. */
tagDescriptions(): Observable<TagDescription[]> {
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<AppProfile>;
/**
* 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<AppProfile>;
watchAppProfile(sourceAndId: string, id?: string): Observable<AppProfile> {
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<AppProfile>(key).pipe(
mergeMap(() => this.portapi.watch<AppProfile>(key)),
finalize(() => {
console.log(
'watchAppProfile: removing cached profile stream for ' + key
);
this.watchedProfiles.delete(key);
}),
share({
connector: () => new BehaviorSubject<AppProfile | null>(null),
resetOnRefCountZero: true,
}),
filter((profile) => profile !== null)
) as Observable<AppProfile>;
this.watchedProfiles.set(key, stream);
return stream;
}
/** @deprecated use saveProfile instead */
saveLocalProfile(profile: AppProfile): Observable<void> {
return this.saveProfile(profile);
}
/**
* Save an application profile.
*
* @param profile The profile to save
*/
saveProfile(profile: AppProfile): Observable<void> {
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<AppProfile[]> {
return this.portapi.watchAll<AppProfile>('core:profiles/');
}
watchLayeredProfile(source: string, id: string): Observable<LayeredProfile>;
/**
* Watches the layered runtime profile for a given application
* profile.
*
* @param profile The app profile
*/
watchLayeredProfile(profile: AppProfile): Observable<LayeredProfile>;
watchLayeredProfile(
profileOrSource: string | AppProfile,
id?: string
): Observable<LayeredProfile> {
if (typeof profileOrSource == 'object') {
id = profileOrSource.ID;
profileOrSource = profileOrSource.Source;
}
const key = `runtime:layeredProfile/${profileOrSource}/${id}`;
return this.portapi.watch<LayeredProfile>(key);
}
/**
* Loads the layered runtime profile for a given application
* profile.
*
* @param profile The app profile
*/
getLayeredProfile(profile: AppProfile): Observable<LayeredProfile> {
const key = `runtime:layeredProfile/${profile.Source}/${profile.ID}`;
return this.portapi.get<LayeredProfile>(key);
}
/**
* Delete an application profile.
*
* @param profile The profile to delete
*/
deleteProfile(profile: AppProfile): Observable<void> {
return this.portapi.delete(`core:profiles/${profile.Source}/${profile.ID}`);
}
getProcessesByProfile(profileOrId: AppProfile | string): Observable<Process[]> {
if (typeof profileOrId === 'object') {
profileOrId = profileOrId.Source + "/" + profileOrId.ID
}
return this.http.get<Process[]>(`${this.httpAPI}/v1/process/list/by-profile/${profileOrId}`)
}
getProcessByPid(pid: number): Observable<Process> {
return this.http.get<Process>(`${this.httpAPI}/v1/process/group-leader/${pid}`)
}
}

View File

@@ -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<T extends OptionValueType>(
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 extends BaseSetting<any, any>>(
s: S
): SettingValueType<S> {
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;
}

View File

@@ -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<boolean>;
/**
* A {@link TrackByFunction} for tracking settings.
*/
static trackBy: TrackByFunction<Setting> = (_: 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<BoolSetting>("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<Setting> {
return this.portapi.get<Setting>(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<Setting[]> {
return this.portapi.query<Setting>(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<void>;
/**
* 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<void>;
// save is overloaded, see above.
save(s: Setting | string, v?: any): Observable<void> {
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<T extends Setting>(key: string): Observable<SettingValueType<T>> {
return this.portapi.qsub<BaseSetting<SettingValueType<T>, 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<S extends Setting>(spec: S, value: SettingValueType<S>) {
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
}
}
}

View File

@@ -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<T extends OptionValueType> {
// 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<T> | QuickSetting<T>[] | CountrySelectionQuickSetting<T> | CountrySelectionQuickSetting<T>[];
[WellKnown.Requires]?: ValueRequirement | ValueRequirement[];
[WellKnown.RequiresFeatureID]?: FeatureID | FeatureID[];
[WellKnown.RequiresUIReload]?: unknown,
// Any thing else...
[key: string]: any;
}
export interface PossilbeValue<T = any> {
/** 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<T extends OptionValueType> {
// 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<T extends OptionValueType> extends QuickSetting<T> {
// 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<T extends OptionValueType, O extends OptionType> 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<T>;
// 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<number, OptionType.Int>;
export type StringSetting = BaseSetting<string, OptionType.String>;
export type StringArraySetting = BaseSetting<string[], OptionType.StringArray>;
export type BoolSetting = BaseSetting<boolean, OptionType.Bool>;
/**
* 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<V extends OptionValueType>(current: V | null, qs: QuickSetting<V>): 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 extends Setting>(s: S): SettingValueType<S>[] {
if (!s.ValidationRegex) {
return [];
}
const values = s.ValidationRegex.match(/\w+/gmi);
const result: SettingValueType<S>[] = [];
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<T extends OptionValueType>(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<S extends Setting>(spec: S, value SettingValueType<S>) { ... }
*/
export type SettingValueType<S extends Setting> = S extends { DefaultValue: infer T } ? T : any;
export type Setting = IntSetting
| StringSetting
| StringArraySetting
| BoolSetting;

View File

@@ -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<Identifyable> = (_: 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()
}

View File

@@ -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<string> {
return this.http.get(`${this.httpAPI}/v1/ping`, {
responseType: 'text'
})
}
getStack(): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/stack`, {
responseType: 'text'
})
}
getDebugInfo(style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/info`, {
params: {
style,
},
responseType: 'text',
})
}
getCoreDebugInfo(style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/core`, {
params: {
style,
},
responseType: 'text',
})
}
getProfileDebugInfo(source: string, id: string, style = 'github'): Observable<string> {
return this.http.get(`${this.httpAPI}/v1/debug/network`, {
params: {
profile: `${source}/${id}`,
style,
},
responseType: 'text',
})
}
}

View File

@@ -0,0 +1,8 @@
export enum FeatureID {
None = "",
SPN = "spn",
PrioritySupport = "support",
History = "history",
Bandwidth = "bw-vis",
VPNCompat = "vpn-compat",
}

View File

@@ -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<MetaEndpoint[]> {
return this.http.get<MetaEndpoint[]>(`${this.httpEndpoint}/v1/endpoints`)
}
permissions(): Observable<AuthPermission> {
return this.http.get<AuthPermission>(`${this.httpEndpoint}/v1/auth/permissions`)
}
myProfile(): Observable<MyProfileResponse> {
return this.http.get<MyProfileResponse>(`${this.httpEndpoint}/v1/app/profile`)
}
requestApplicationAccess(appName: string, read: 'user' | 'admin' = 'user', write: 'user' | 'admin' = 'user'): Observable<AuthKeyResponse> {
let params = new HttpParams()
.set("app-name", appName)
.set("read", read)
.set("write", write)
return this.http.get<AuthKeyResponse>(`${this.httpEndpoint}/v1/app/auth`, { params })
}
login(bearer: string): Observable<boolean>;
login(username: string, password: string): Observable<boolean>;
login(usernameOrBearer: string, password?: string): Observable<boolean> {
let login: Observable<void>;
if (!!password) {
login = this.http.get<void>(`${this.httpEndpoint}/v1/auth/basic`, {
headers: {
'Authorization': `Basic ${btoa(usernameOrBearer + ":" + password)}`
}
})
} else {
login = this.http.get<void>(`${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<void> {
return this.http.get<void>(`${this.httpEndpoint}/v1/auth/reset`);
}
}

View File

@@ -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<PortmasterAPIModule> {
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
}
]
}
}
}

View File

@@ -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<NetqueryConnection> {
[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<T> = {
[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<K extends ConnKeys> = {
[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<QueryResult[]> {
return this.http.post<{ results: QueryResult[] }>(`${this.httpAPI}/v1/netquery/query`, query, {
params: new HttpParams().set("origin", origin)
})
.pipe(map(res => res.results || []));
}
batch<T extends BatchRequest>(queries: T): Observable<BatchResponse<T>> {
return this.http.post<BatchResponse<T>>(`${this.httpAPI}/v1/netquery/query/batch`, queries)
}
cleanProfileHistory(profileIDs: string | string[]): Observable<HttpResponse<any>> {
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<K extends ConnKeys>(query: Condition, groupBy?: K[], interval?: number): Observable<BandwidthChartResult<K>[]> {
return this.http.post<{ results: BandwidthChartResult<K>[] }>(`${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<ChartResult[]> {
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<string[]> {
return this.query({
select: [
'profile',
],
groupBy: [
'profile',
],
}, 'get-active-profile-ids').pipe(
map(result => {
return result.map(res => res.profile!);
})
)
}
getActiveProfiles(): Observable<AppProfile[]> {
return this.getActiveProfileIDs()
.pipe(
mergeMap(profiles => forkJoin(profiles.map(pid => this.profileService.getAppProfile(pid))))
)
}
getProfileStats(query?: Condition): Observable<IProfileStats[]> {
let profileCache = new Map<string, AppProfile>();
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<string, IProfileStats>();
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<string, IProfileStats>();
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())
})
)
})
)
}
}

View File

@@ -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<dateType extends string | Date = string> {
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<M extends MessageType = MessageType> {
// 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<T extends Record> 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<any> {
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<T> 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<T extends Record = any> = DataReply<T>
| DoneReply
| SuccessReply
| WarningReply
| ErrorReply;
/**
* RequestMessage is a union of all request message types.
*/
export type RequestMessage<T = any> = QueryRequest
| KeyRequest
| DataRequest<T>
| CancelRequest;
/**
* Requestable can be used to accept only properties that match
* the request message type M.
*/
export type Requestable<M extends RequestType> = 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<DataReply<any>>;
// 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<T>({ retryDelay, maxRetries }: RetryableOpts = {}): MonoTypeOperatorFunction<T> {
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<RequestMessage | ReplyMessage> = {
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
}

View File

@@ -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<SPNStatus>;
profile$ = this.watchProfile()
.pipe(
share({ connector: () => new BehaviorSubject<UserProfile | null | undefined>(undefined) }),
filter(val => val !== undefined)
) as Observable<UserProfile | null>;
private pins$: Observable<Pin[]>;
constructor(
private portapi: PortapiService,
private http: HttpClient,
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
) {
this.status$ = this.portapi.watch<SPNStatus>('runtime:spn/status', { ignoreDelete: true })
.pipe(
share({ connector: () => new BehaviorSubject<any | null>(null) }),
filter(val => val !== null),
)
this.pins$ = this.status$
.pipe(
switchMap(status => {
if (status.Status !== "disabled") {
return this.portapi.watchAll<Pin>("map:main/", { retryDelay: 50000 })
}
return of([] as Pin[]);
}),
share({ connector: () => new BehaviorSubject<Pin[] | undefined>(undefined) }),
filter(val => val !== undefined)
) as Observable<Pin[]>;
}
/**
* Watches all pins of the "main" SPN map.
*/
watchPins(): Observable<Pin[]> {
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<HttpResponse<string>> {
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<HttpResponse<string>> {
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<Feature[]> {
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<UserProfile> {
let params = new HttpParams();
if (!!refresh) {
params = params.set("refresh", true)
}
return this.http.get<UserProfile>(`${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<UserProfile | null> {
let hasSent = false;
return this.portapi.watch<UserProfile>('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;
})
);
}
}

View File

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

View File

@@ -0,0 +1,13 @@
export function deepClone<T = any>(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;
}

View File

@@ -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<T>(opts: WebSocketSubjectConfig<T>): WebSocketSubject<T> {
return webSocket(opts);
}
}

View File

@@ -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';

View File

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