+ The server, or at least the exact URL you have accessed, leads to an SPN Node.
+
+
+
+
+
+ What is SPN?
+
+
+ SPN stands for "Safing Privacy Network" and is a network of servers that offers high privacy protection of Internet traffic and activity. It was built to replace VPNs for their Internet privacy use case.
+
+ {{ if .ContactAddress }}
+ You can reach the operator of this SPN Node here:
+ {{ .ContactAddress }}
+ {{ if .ContactService }} via {{ .ContactService }}
+ {{ end }}
+ {{ else }}
+ The operator of this SPN Node has not configured any contact data.
+ Please contact the operator using the usual methods via the hosting provider.
+ {{ end }}
+
+
+
+
+
+ Are You Tracing Bad Activity?
+
+
+ We are sorry there is an incident involving this server. We condemn any disruptive or illegal activity.
+
+
+ Please note that servers are not only operated by Safing (the company behind SPN), but also by third parties.
+
+
+ The SPN works very similar to Tor. Its primary goal is to provide people more privacy on the Internet. We also provide our services to people behind censoring firewalls in oppressive regimes.
+
+
+ This server does not host any content (as part of its role in the SPN network). Rather, it is part of the network where nodes on the Internet simply pass packets among themselves before sending them to their destinations, just as any Internet intermediary does.
+
+
+ Please understand that the SPN makes it technically impossible to single out individual users. We are also legally bound to respective privacy rights.
+
+
+ We can offer to block specific destination IPs and ports, but the abuser doesn't use this server specifically; instead, they will just be routed through a different exit node outside of our control.
+
+
+
+
+
+ SPN Node Info
+
+
+
+
Name: {{ .Name }}
+
Group: {{ .Group }}
+
ContactAddress: {{ .ContactAddress }}
+
ContactService: {{ .ContactService }}
+
Version: {{ .Version }}
+
ID: {{ .ID }}
+
+ Build:
+
+
Commit: {{ .Info.Commit }}
+
Host: {{ .Info.BuildHost }}
+
Date: {{ .Info.BuildDate }}
+
Source: {{ .Info.BuildSource }}
+
+
+
+
+
+
+
+
diff --git a/spn/ships/http_info_test.go b/spn/ships/http_info_test.go
new file mode 100644
index 00000000..a490dfce
--- /dev/null
+++ b/spn/ships/http_info_test.go
@@ -0,0 +1,26 @@
+package ships
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/safing/portbase/config"
+)
+
+func TestInfoPageTemplate(t *testing.T) {
+ t.Parallel()
+
+ infoPageTemplate = template.Must(template.New("info-page").Parse(infoPageData))
+ pageInputName = config.Concurrent.GetAsString("spn/publicHub/name", "node-name")
+ pageInputGroup = config.Concurrent.GetAsString("spn/publicHub/group", "node-group")
+ pageInputContactAddress = config.Concurrent.GetAsString("spn/publicHub/contactAddress", "john@doe.com")
+ pageInputContactService = config.Concurrent.GetAsString("spn/publicHub/contactService", "email")
+
+ pageData, err := renderInfoPage()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _ = pageData
+ // t.Log(string(pageData))
+}
diff --git a/spn/ships/http_shared.go b/spn/ships/http_shared.go
new file mode 100644
index 00000000..c90504e1
--- /dev/null
+++ b/spn/ships/http_shared.go
@@ -0,0 +1,188 @@
+package ships
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/spn/conf"
+)
+
+type sharedServer struct {
+ server *http.Server
+
+ handlers map[string]http.HandlerFunc
+ handlersLock sync.RWMutex
+}
+
+// ServeHTTP forwards requests to registered handler or uses defaults.
+func (shared *sharedServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ shared.handlersLock.Lock()
+ defer shared.handlersLock.Unlock()
+
+ // Get and forward to registered handler.
+ handler, ok := shared.handlers[r.URL.Path]
+ if ok {
+ handler(w, r)
+ return
+ }
+
+ // If there is registered handler and path is "/", respond with info page.
+ if r.Method == http.MethodGet && r.URL.Path == "/" {
+ ServeInfoPage(w, r)
+ return
+ }
+
+ // Otherwise, respond with error.
+ http.Error(w, "", http.StatusNotFound)
+}
+
+var (
+ sharedHTTPServers = make(map[uint16]*sharedServer)
+ sharedHTTPServersLock sync.Mutex
+)
+
+func addHTTPHandler(port uint16, path string, handler http.HandlerFunc) error {
+ // Check params.
+ if port == 0 {
+ return errors.New("cannot listen on port 0")
+ }
+
+ // Default to root path.
+ if path == "" {
+ path = "/"
+ }
+
+ sharedHTTPServersLock.Lock()
+ defer sharedHTTPServersLock.Unlock()
+
+ // Get http server of the port.
+ shared, ok := sharedHTTPServers[port]
+ if ok {
+ // Set path to handler.
+ shared.handlersLock.Lock()
+ defer shared.handlersLock.Unlock()
+
+ // Check if path is already registered.
+ _, ok := shared.handlers[path]
+ if ok {
+ return errors.New("path already registered")
+ }
+
+ // Else, register handler at path.
+ shared.handlers[path] = handler
+ return nil
+ }
+
+ // Shared server does not exist - create one.
+ shared = &sharedServer{
+ handlers: make(map[string]http.HandlerFunc),
+ }
+
+ // Add first handler.
+ shared.handlers[path] = handler
+
+ // Define new server.
+ server := &http.Server{
+ Addr: fmt.Sprintf(":%d", port),
+ Handler: shared,
+ ReadTimeout: 1 * time.Minute,
+ ReadHeaderTimeout: 10 * time.Second,
+ WriteTimeout: 1 * time.Minute,
+ IdleTimeout: 1 * time.Minute,
+ MaxHeaderBytes: 4096,
+ // ErrorLog: &log.Logger{}, // FIXME
+ BaseContext: func(net.Listener) context.Context { return module.Ctx },
+ }
+ shared.server = server
+
+ // Start listeners.
+ bindIPs := conf.GetBindIPs()
+ listeners := make([]net.Listener, 0, len(bindIPs))
+ for _, bindIP := range bindIPs {
+ listener, err := net.ListenTCP("tcp", &net.TCPAddr{
+ IP: bindIP,
+ Port: int(port),
+ })
+ if err != nil {
+ return fmt.Errorf("failed to listen: %w", err)
+ }
+
+ listeners = append(listeners, listener)
+ log.Infof("spn/ships: http transport pier established on %s", listener.Addr())
+ }
+
+ // Add shared http server to list.
+ sharedHTTPServers[port] = shared
+
+ // Start servers in service workers.
+ for _, listener := range listeners {
+ serviceListener := listener
+ module.StartServiceWorker(
+ fmt.Sprintf("shared http server listener on %s", listener.Addr()), 0,
+ func(ctx context.Context) error {
+ err := shared.server.Serve(serviceListener)
+ if !errors.Is(http.ErrServerClosed, err) {
+ return err
+ }
+ return nil
+ },
+ )
+ }
+
+ return nil
+}
+
+func removeHTTPHandler(port uint16, path string) error {
+ // Check params.
+ if port == 0 {
+ return nil
+ }
+
+ // Default to root path.
+ if path == "" {
+ path = "/"
+ }
+
+ sharedHTTPServersLock.Lock()
+ defer sharedHTTPServersLock.Unlock()
+
+ // Get http server of the port.
+ shared, ok := sharedHTTPServers[port]
+ if !ok {
+ return nil
+ }
+
+ // Set path to handler.
+ shared.handlersLock.Lock()
+ defer shared.handlersLock.Unlock()
+
+ // Check if path is registered.
+ _, ok = shared.handlers[path]
+ if !ok {
+ return nil
+ }
+
+ // Remove path from handler.
+ delete(shared.handlers, path)
+
+ // Shutdown shared HTTP server if no more handlers are registered.
+ if len(shared.handlers) == 0 {
+ ctx, cancel := context.WithTimeout(
+ context.Background(),
+ 10*time.Second,
+ )
+ defer cancel()
+ return shared.server.Shutdown(ctx)
+ }
+
+ // Remove shared HTTP server from map.
+ delete(sharedHTTPServers, port)
+
+ return nil
+}
diff --git a/spn/ships/http_shared_test.go b/spn/ships/http_shared_test.go
new file mode 100644
index 00000000..e16ff53d
--- /dev/null
+++ b/spn/ships/http_shared_test.go
@@ -0,0 +1,33 @@
+package ships
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSharedHTTP(t *testing.T) { //nolint:paralleltest // Test checks global state.
+ const testPort = 65100
+
+ // Register multiple handlers.
+ err := addHTTPHandler(testPort, "", ServeInfoPage)
+ assert.NoError(t, err, "should be able to share http listener")
+ err = addHTTPHandler(testPort, "/test", ServeInfoPage)
+ assert.NoError(t, err, "should be able to share http listener")
+ err = addHTTPHandler(testPort, "/test2", ServeInfoPage)
+ assert.NoError(t, err, "should be able to share http listener")
+ err = addHTTPHandler(testPort, "/", ServeInfoPage)
+ assert.Error(t, err, "should fail to register path twice")
+
+ // Unregister
+ assert.NoError(t, removeHTTPHandler(testPort, ""))
+ assert.NoError(t, removeHTTPHandler(testPort, "/test"))
+ assert.NoError(t, removeHTTPHandler(testPort, "/not-registered")) // removing unregistered handler does not error
+ assert.NoError(t, removeHTTPHandler(testPort, "/test2"))
+ assert.NoError(t, removeHTTPHandler(testPort, "/not-registered")) // removing unregistered handler does not error
+
+ // Check if all handlers are gone again.
+ sharedHTTPServersLock.Lock()
+ defer sharedHTTPServersLock.Unlock()
+ assert.Equal(t, 0, len(sharedHTTPServers), "shared http handlers should be back to zero")
+}
diff --git a/spn/ships/kcp.go b/spn/ships/kcp.go
new file mode 100644
index 00000000..88bfb2ad
--- /dev/null
+++ b/spn/ships/kcp.go
@@ -0,0 +1,81 @@
+package ships
+
+// KCPShip is a ship that uses KCP.
+type KCPShip struct {
+ ShipBase
+}
+
+// KCPPier is a pier that uses KCP.
+type KCPPier struct {
+ PierBase
+}
+
+// TODO: Find a replacement for kcp, which turned out to not fit our use case.
+/*
+func init() {
+ Register("kcp", &Builder{
+ LaunchShip: launchKCPShip,
+ EstablishPier: establishKCPPier,
+ })
+}
+
+func launchKCPShip(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) {
+ conn, err := kcp.Dial(net.JoinHostPort(ip.String(), portToA(transport.Port)))
+ if err != nil {
+ return nil, err
+ }
+
+ ship := &KCPShip{
+ ShipBase: ShipBase{
+ conn: conn,
+ transport: transport,
+ mine: true,
+ secure: false,
+ // Calculate KCP's MSS.
+ loadSize: kcp.IKCP_MTU_DEF - kcp.IKCP_OVERHEAD,
+ },
+ }
+
+ ship.initBase()
+ return ship, nil
+}
+
+func establishKCPPier(transport *hub.Transport, dockingRequests chan *DockingRequest) (Pier, error) {
+ listener, err := kcp.Listen(net.JoinHostPort("", portToA(transport.Port)))
+ if err != nil {
+ return nil, err
+ }
+
+ pier := &KCPPier{
+ PierBase: PierBase{
+ transport: transport,
+ listener: listener,
+ dockingRequests: dockingRequests,
+ },
+ }
+ pier.PierBase.dockShip = pier.dockShip
+ pier.initBase()
+ return pier, nil
+}
+
+func (pier *KCPPier) dockShip() (Ship, error) {
+ conn, err := pier.listener.Accept()
+ if err != nil {
+ return nil, err
+ }
+
+ ship := &KCPShip{
+ ShipBase: ShipBase{
+ conn: conn,
+ transport: pier.transport,
+ mine: false,
+ secure: false,
+ // Calculate KCP's MSS.
+ loadSize: kcp.IKCP_MTU_DEF - kcp.IKCP_OVERHEAD,
+ },
+ }
+
+ ship.initBase()
+ return ship, nil
+}
+*/
diff --git a/spn/ships/launch.go b/spn/ships/launch.go
new file mode 100644
index 00000000..45a77834
--- /dev/null
+++ b/spn/ships/launch.go
@@ -0,0 +1,114 @@
+package ships
+
+import (
+ "context"
+ "fmt"
+ "net"
+
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/service/netenv"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+// Launch launches a new ship to the given Hub.
+func Launch(ctx context.Context, h *hub.Hub, transport *hub.Transport, ip net.IP) (Ship, error) {
+ var transports []*hub.Transport
+ var ips []net.IP
+
+ // choose transports
+ if transport != nil {
+ transports = []*hub.Transport{transport}
+ } else {
+ if h.Info == nil {
+ return nil, hub.ErrMissingInfo
+ }
+ transports = h.Info.ParsedTransports()
+ // If there are no transports, check if they were parsed.
+ if len(transports) == 0 && len(h.Info.Transports) > 0 {
+ log.Errorf("ships: %s has no parsed transports, but transports are %v", h, h.Info.Transports)
+ // Attempt to parse transports now.
+ transports, _ = hub.ParseTransports(h.Info.Transports)
+ }
+ // Fail if there are not transports.
+ if len(transports) == 0 {
+ return nil, hub.ErrMissingTransports
+ }
+ }
+
+ // choose IPs
+ if ip != nil {
+ ips = []net.IP{ip}
+ } else {
+ if h.Info == nil {
+ return nil, hub.ErrMissingInfo
+ }
+ ips = make([]net.IP, 0, 3)
+ // If IPs have been verified, check if we can use a virtual network address.
+ var vnetForced bool
+ if h.VerifiedIPs {
+ vnet := GetVirtualNetworkConfig()
+ if vnet != nil {
+ virtIP := vnet.Mapping[h.ID]
+ if virtIP != nil {
+ ips = append(ips, virtIP)
+ if vnet.Force {
+ vnetForced = true
+ log.Infof("spn/ships: forcing virtual network address %s for %s", virtIP, h)
+ } else {
+ log.Infof("spn/ships: using virtual network address %s for %s", virtIP, h)
+ }
+ }
+ }
+ }
+ // Add Hub's IPs if no virtual address was forced.
+ if !vnetForced {
+ // prioritize IPv4
+ if h.Info.IPv4 != nil {
+ ips = append(ips, h.Info.IPv4)
+ }
+ if h.Info.IPv6 != nil && netenv.IPv6Enabled() {
+ ips = append(ips, h.Info.IPv6)
+ }
+ }
+ if len(ips) == 0 {
+ return nil, hub.ErrMissingIPs
+ }
+ }
+
+ // connect
+ var firstErr error
+ for _, ip := range ips {
+ for _, tr := range transports {
+ ship, err := connectTo(ctx, h, tr, ip)
+ if err == nil {
+ return ship, nil // return on success
+ }
+
+ // Check if context is canceled.
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+
+ // Save first error.
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ }
+
+ return nil, firstErr
+}
+
+func connectTo(ctx context.Context, h *hub.Hub, transport *hub.Transport, ip net.IP) (Ship, error) {
+ builder := GetBuilder(transport.Protocol)
+ if builder == nil {
+ return nil, fmt.Errorf("protocol %s not supported", transport.Protocol)
+ }
+
+ ship, err := builder.LaunchShip(ctx, transport, ip)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to %s using %s (%s): %w", h, transport, ip, err)
+ }
+
+ return ship, nil
+}
diff --git a/spn/ships/masking.go b/spn/ships/masking.go
new file mode 100644
index 00000000..76d9fc37
--- /dev/null
+++ b/spn/ships/masking.go
@@ -0,0 +1,63 @@
+package ships
+
+import (
+ "crypto/sha1"
+ "net"
+
+ "github.com/mr-tron/base58"
+ "github.com/tevino/abool"
+)
+
+var (
+ maskingEnabled = abool.New()
+ maskingActive = abool.New()
+ maskingBytes []byte
+)
+
+// EnableMasking enables masking with the given salt.
+func EnableMasking(salt []byte) {
+ if maskingEnabled.SetToIf(false, true) {
+ maskingBytes = salt
+ maskingActive.Set()
+ }
+}
+
+// MaskAddress masks the given address if masking is enabled and the ship is
+// not public.
+func (ship *ShipBase) MaskAddress(addr net.Addr) string {
+ // Return in plain if masking is not enabled or if ship is public.
+ if maskingActive.IsNotSet() || ship.Public() {
+ return addr.String()
+ }
+
+ switch typedAddr := addr.(type) {
+ case *net.TCPAddr:
+ return ship.MaskIP(typedAddr.IP)
+ case *net.UDPAddr:
+ return ship.MaskIP(typedAddr.IP)
+ default:
+ return ship.Mask([]byte(addr.String()))
+ }
+}
+
+// MaskIP masks the given IP if masking is enabled and the ship is not public.
+func (ship *ShipBase) MaskIP(ip net.IP) string {
+ // Return in plain if masking is not enabled or if ship is public.
+ if maskingActive.IsNotSet() || ship.Public() {
+ return ip.String()
+ }
+
+ return ship.Mask(ip)
+}
+
+// Mask masks the given value.
+func (ship *ShipBase) Mask(value []byte) string {
+ // Hash the IP with masking bytes.
+ hasher := sha1.New() //nolint:gosec // Not used for cryptography.
+ hasher.Write(maskingBytes)
+ hasher.Write(value)
+ masked := hasher.Sum(nil)
+
+ // Return first 8 characters from the base58-encoded hash.
+ return "masked:" + base58.Encode(masked)[:8]
+}
diff --git a/spn/ships/module.go b/spn/ships/module.go
new file mode 100644
index 00000000..d450185e
--- /dev/null
+++ b/spn/ships/module.go
@@ -0,0 +1,20 @@
+package ships
+
+import (
+ "github.com/safing/portbase/modules"
+ "github.com/safing/portmaster/spn/conf"
+)
+
+var module *modules.Module
+
+func init() {
+ module = modules.Register("ships", start, nil, nil, "cabin")
+}
+
+func start() error {
+ if conf.PublicHub() {
+ initPageInput()
+ }
+
+ return nil
+}
diff --git a/spn/ships/mtu.go b/spn/ships/mtu.go
new file mode 100644
index 00000000..07bb1a14
--- /dev/null
+++ b/spn/ships/mtu.go
@@ -0,0 +1,47 @@
+package ships
+
+import "net"
+
+// MTU Calculation Configuration.
+const (
+ BaseMTU = 1460 // 1500 with 40 bytes extra space for special cases.
+ IPv4HeaderMTUSize = 20 // Without options, as not common.
+ IPv6HeaderMTUSize = 40 // Without options, as not common.
+ TCPHeaderMTUSize = 60 // Maximum size with options.
+ UDPHeaderMTUSize = 8 // Has no options.
+)
+
+func (ship *ShipBase) calculateLoadSize(ip net.IP, addr net.Addr, subtract ...int) {
+ ship.loadSize = BaseMTU
+
+ // Convert addr to IP if needed.
+ if ip == nil && addr != nil {
+ switch v := addr.(type) {
+ case *net.TCPAddr:
+ ip = v.IP
+ case *net.UDPAddr:
+ ip = v.IP
+ case *net.IPAddr:
+ ip = v.IP
+ }
+ }
+
+ // Subtract IP Header, if IP is available.
+ if ip != nil {
+ if ip4 := ip.To4(); ip4 != nil {
+ ship.loadSize -= IPv4HeaderMTUSize
+ } else {
+ ship.loadSize -= IPv6HeaderMTUSize
+ }
+ }
+
+ // Subtract others.
+ for sub := range subtract {
+ ship.loadSize -= sub
+ }
+
+ // Raise buf size to at least load size.
+ if ship.bufSize < ship.loadSize {
+ ship.bufSize = ship.loadSize
+ }
+}
diff --git a/spn/ships/pier.go b/spn/ships/pier.go
new file mode 100644
index 00000000..78483bf4
--- /dev/null
+++ b/spn/ships/pier.go
@@ -0,0 +1,82 @@
+package ships
+
+import (
+ "fmt"
+ "net"
+
+ "github.com/tevino/abool"
+
+ "github.com/safing/portmaster/spn/hub"
+)
+
+// Pier represents a network connection listener.
+type Pier interface {
+ // String returns a human readable informational summary about the ship.
+ String() string
+
+ // Transport returns the transport used for this ship.
+ Transport() *hub.Transport
+
+ // Abolish closes the underlying listener and cleans up any related resources.
+ Abolish()
+}
+
+// DockingRequest is a uniform request that Piers emit when a new ship arrives.
+type DockingRequest struct {
+ Pier Pier
+ Ship Ship
+ Err error
+}
+
+// EstablishPier is shorthand function to get the transport's builder and establish a pier.
+func EstablishPier(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) {
+ builder := GetBuilder(transport.Protocol)
+ if builder == nil {
+ return nil, fmt.Errorf("protocol %s not supported", transport.Protocol)
+ }
+
+ pier, err := builder.EstablishPier(transport, dockingRequests)
+ if err != nil {
+ return nil, fmt.Errorf("failed to establish pier on %s: %w", transport, err)
+ }
+
+ return pier, nil
+}
+
+// PierBase implements common functions to comply with the Pier interface.
+type PierBase struct {
+ // transport holds the transport definition of the pier.
+ transport *hub.Transport
+ // listeners holds the actual underlying listeners.
+ listeners []net.Listener
+
+ // dockingRequests is used to report new connections to the higher layer.
+ dockingRequests chan Ship
+
+ // abolishing specifies if the pier and listener is being closed.
+ abolishing *abool.AtomicBool
+}
+
+func (pier *PierBase) initBase() {
+ // init
+ pier.abolishing = abool.New()
+}
+
+// String returns a human readable informational summary about the ship.
+func (pier *PierBase) String() string {
+ return fmt.Sprintf("", pier.transport)
+}
+
+// Transport returns the transport used for this ship.
+func (pier *PierBase) Transport() *hub.Transport {
+ return pier.transport
+}
+
+// Abolish closes the underlying listener and cleans up any related resources.
+func (pier *PierBase) Abolish() {
+ if pier.abolishing.SetToIf(false, true) {
+ for _, listener := range pier.listeners {
+ _ = listener.Close()
+ }
+ }
+}
diff --git a/spn/ships/registry.go b/spn/ships/registry.go
new file mode 100644
index 00000000..5d3abba7
--- /dev/null
+++ b/spn/ships/registry.go
@@ -0,0 +1,55 @@
+package ships
+
+import (
+ "context"
+ "net"
+ "strconv"
+ "sync"
+
+ "github.com/safing/portmaster/spn/hub"
+)
+
+// Builder is a factory that can build ships and piers of it's protocol.
+type Builder struct {
+ LaunchShip func(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error)
+ EstablishPier func(transport *hub.Transport, dockingRequests chan Ship) (Pier, error)
+}
+
+var (
+ registry = make(map[string]*Builder)
+ allProtocols []string
+ registryLock sync.Mutex
+)
+
+// Register registers a new builder for a protocol.
+func Register(protocol string, builder *Builder) {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ registry[protocol] = builder
+}
+
+// GetBuilder returns the builder for the given protocol, or nil if it does not exist.
+func GetBuilder(protocol string) *Builder {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ builder, ok := registry[protocol]
+ if !ok {
+ return nil
+ }
+ return builder
+}
+
+// Protocols returns a slice with all registered protocol names. The return slice must not be edited.
+func Protocols() []string {
+ registryLock.Lock()
+ defer registryLock.Unlock()
+
+ return allProtocols
+}
+
+// portToA transforms the given port into a string.
+func portToA(port uint16) string {
+ return strconv.FormatUint(uint64(port), 10)
+}
diff --git a/spn/ships/ship.go b/spn/ships/ship.go
new file mode 100644
index 00000000..4bb39b0e
--- /dev/null
+++ b/spn/ships/ship.go
@@ -0,0 +1,220 @@
+package ships
+
+import (
+ "errors"
+ "fmt"
+ "net"
+
+ "github.com/tevino/abool"
+
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+const (
+ defaultLoadSize = 4096
+)
+
+// ErrSunk is returned when a ship sunk, ie. the connection was lost.
+var ErrSunk = errors.New("ship sunk")
+
+// Ship represents a network layer connection.
+type Ship interface {
+ // String returns a human readable informational summary about the ship.
+ String() string
+
+ // Transport returns the transport used for this ship.
+ Transport() *hub.Transport
+
+ // IsMine returns whether the ship was launched from here.
+ IsMine() bool
+
+ // IsSecure returns whether the ship provides transport security.
+ IsSecure() bool
+
+ // Public returns whether the ship is marked as public.
+ Public() bool
+
+ // MarkPublic marks the ship as public.
+ MarkPublic()
+
+ // LoadSize returns the recommended data size that should be handed to Load().
+ // This value will be most likely somehow related to the connection's MTU.
+ // Alternatively, using a multiple of LoadSize is also recommended.
+ LoadSize() int
+
+ // Load loads data into the ship - ie. sends the data via the connection.
+ // Returns ErrSunk if the ship has already sunk earlier.
+ Load(data []byte) error
+
+ // UnloadTo unloads data from the ship - ie. receives data from the
+ // connection - puts it into the buf. It returns the amount of data
+ // written and an optional error.
+ // Returns ErrSunk if the ship has already sunk earlier.
+ UnloadTo(buf []byte) (n int, err error)
+
+ // LocalAddr returns the underlying local net.Addr of the connection.
+ LocalAddr() net.Addr
+
+ // RemoteAddr returns the underlying remote net.Addr of the connection.
+ RemoteAddr() net.Addr
+
+ // Sink closes the underlying connection and cleans up any related resources.
+ Sink()
+
+ // MaskAddress masks the address, if enabled.
+ MaskAddress(addr net.Addr) string
+ // MaskIP masks an IP, if enabled.
+ MaskIP(ip net.IP) string
+ // Mask masks a value.
+ Mask(value []byte) string
+}
+
+// ShipBase implements common functions to comply with the Ship interface.
+type ShipBase struct {
+ // conn is the actual underlying connection.
+ conn net.Conn
+ // transport holds the transport definition of the ship.
+ transport *hub.Transport
+
+ // mine specifies whether the ship was launched from here.
+ mine bool
+ // secure specifies whether the ship provides transport security.
+ secure bool
+ // public specifies whether the ship is public.
+ public *abool.AtomicBool
+ // bufSize specifies the size of the receive buffer.
+ bufSize int
+ // loadSize specifies the recommended data size that should be handed to Load().
+ loadSize int
+
+ // initial holds initial data from setting up the ship.
+ initial []byte
+ // sinking specifies if the connection is being closed.
+ sinking *abool.AtomicBool
+}
+
+func (ship *ShipBase) initBase() {
+ // init
+ ship.sinking = abool.New()
+ ship.public = abool.New()
+
+ // set default
+ if ship.loadSize == 0 {
+ ship.loadSize = defaultLoadSize
+ }
+ if ship.bufSize == 0 {
+ ship.bufSize = ship.loadSize
+ }
+}
+
+// String returns a human readable informational summary about the ship.
+func (ship *ShipBase) String() string {
+ if ship.mine {
+ return fmt.Sprintf("", ship.MaskAddress(ship.RemoteAddr()), ship.transport)
+ }
+ return fmt.Sprintf("", ship.MaskAddress(ship.RemoteAddr()), ship.transport)
+}
+
+// Transport returns the transport used for this ship.
+func (ship *ShipBase) Transport() *hub.Transport {
+ return ship.transport
+}
+
+// IsMine returns whether the ship was launched from here.
+func (ship *ShipBase) IsMine() bool {
+ return ship.mine
+}
+
+// IsSecure returns whether the ship provides transport security.
+func (ship *ShipBase) IsSecure() bool {
+ return ship.secure
+}
+
+// Public returns whether the ship is marked as public.
+func (ship *ShipBase) Public() bool {
+ return ship.public.IsSet()
+}
+
+// MarkPublic marks the ship as public.
+func (ship *ShipBase) MarkPublic() {
+ ship.public.Set()
+}
+
+// LoadSize returns the recommended data size that should be handed to Load().
+// This value will be most likely somehow related to the connection's MTU.
+// Alternatively, using a multiple of LoadSize is also recommended.
+func (ship *ShipBase) LoadSize() int {
+ return ship.loadSize
+}
+
+// Load loads data into the ship - ie. sends the data via the connection.
+// Returns ErrSunk if the ship has already sunk earlier.
+func (ship *ShipBase) Load(data []byte) error {
+ // Empty load is used as a signal to cease operaetion.
+ if len(data) == 0 {
+ if ship.sinking.SetToIf(false, true) {
+ _ = ship.conn.Close()
+ }
+ return nil
+ }
+
+ // Send all given data.
+ n, err := ship.conn.Write(data)
+ switch {
+ case err != nil:
+ return err
+ case n == 0:
+ return errors.New("loaded 0 bytes")
+ case n < len(data):
+ // If not all data was sent, try again.
+ log.Debugf("spn/ships: %s only loaded %d/%d bytes", ship, n, len(data))
+ data = data[n:]
+ return ship.Load(data)
+ }
+
+ return nil
+}
+
+// UnloadTo unloads data from the ship - ie. receives data from the
+// connection - puts it into the buf. It returns the amount of data
+// written and an optional error.
+// Returns ErrSunk if the ship has already sunk earlier.
+func (ship *ShipBase) UnloadTo(buf []byte) (n int, err error) {
+ // Process initial data, if there is any.
+ if ship.initial != nil {
+ // Copy as much data as possible.
+ copy(buf, ship.initial)
+
+ // If buf was too small, skip the copied section.
+ if len(buf) < len(ship.initial) {
+ ship.initial = ship.initial[len(buf):]
+ return len(buf), nil
+ }
+
+ // If everything was copied, unset the initial data.
+ n := len(ship.initial)
+ ship.initial = nil
+ return n, nil
+ }
+
+ // Receive data.
+ return ship.conn.Read(buf)
+}
+
+// LocalAddr returns the underlying local net.Addr of the connection.
+func (ship *ShipBase) LocalAddr() net.Addr {
+ return ship.conn.LocalAddr()
+}
+
+// RemoteAddr returns the underlying remote net.Addr of the connection.
+func (ship *ShipBase) RemoteAddr() net.Addr {
+ return ship.conn.RemoteAddr()
+}
+
+// Sink closes the underlying connection and cleans up any related resources.
+func (ship *ShipBase) Sink() {
+ if ship.sinking.SetToIf(false, true) {
+ _ = ship.conn.Close()
+ }
+}
diff --git a/spn/ships/tcp.go b/spn/ships/tcp.go
new file mode 100644
index 00000000..5ffd5b90
--- /dev/null
+++ b/spn/ships/tcp.go
@@ -0,0 +1,145 @@
+package ships
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/spn/conf"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+// TCPShip is a ship that uses TCP.
+type TCPShip struct {
+ ShipBase
+}
+
+// TCPPier is a pier that uses TCP.
+type TCPPier struct {
+ PierBase
+
+ ctx context.Context
+ cancelCtx context.CancelFunc
+}
+
+func init() {
+ Register("tcp", &Builder{
+ LaunchShip: launchTCPShip,
+ EstablishPier: establishTCPPier,
+ })
+}
+
+func launchTCPShip(ctx context.Context, transport *hub.Transport, ip net.IP) (Ship, error) {
+ var dialNet string
+ if ip4 := ip.To4(); ip4 != nil {
+ dialNet = "tcp4"
+ } else {
+ dialNet = "tcp6"
+ }
+ dialer := &net.Dialer{
+ Timeout: 30 * time.Second,
+ LocalAddr: conf.GetBindAddr(dialNet),
+ FallbackDelay: -1, // Disables Fast Fallback from IPv6 to IPv4.
+ KeepAlive: -1, // Disable keep-alive.
+ }
+ conn, err := dialer.DialContext(ctx, dialNet, net.JoinHostPort(ip.String(), portToA(transport.Port)))
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect: %w", err)
+ }
+
+ ship := &TCPShip{
+ ShipBase: ShipBase{
+ conn: conn,
+ transport: transport,
+ mine: true,
+ secure: false,
+ },
+ }
+
+ ship.calculateLoadSize(ip, nil, TCPHeaderMTUSize)
+ ship.initBase()
+ return ship, nil
+}
+
+func establishTCPPier(transport *hub.Transport, dockingRequests chan Ship) (Pier, error) {
+ // Start listeners.
+ bindIPs := conf.GetBindIPs()
+ listeners := make([]net.Listener, 0, len(bindIPs))
+ for _, bindIP := range bindIPs {
+ listener, err := net.ListenTCP("tcp", &net.TCPAddr{
+ IP: bindIP,
+ Port: int(transport.Port),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to listen: %w", err)
+ }
+
+ listeners = append(listeners, listener)
+ log.Infof("spn/ships: tcp transport pier established on %s", listener.Addr())
+ }
+
+ // Create new pier.
+ pierCtx, cancelCtx := context.WithCancel(module.Ctx)
+ pier := &TCPPier{
+ PierBase: PierBase{
+ transport: transport,
+ listeners: listeners,
+ dockingRequests: dockingRequests,
+ },
+ ctx: pierCtx,
+ cancelCtx: cancelCtx,
+ }
+ pier.initBase()
+
+ // Start workers.
+ for _, listener := range pier.listeners {
+ serviceListener := listener
+ module.StartServiceWorker("accept TCP docking requests", 0, func(ctx context.Context) error {
+ return pier.dockingWorker(ctx, serviceListener)
+ })
+ }
+
+ return pier, nil
+}
+
+func (pier *TCPPier) dockingWorker(_ context.Context, listener net.Listener) error {
+ for {
+ // Block until something happens.
+ conn, err := listener.Accept()
+
+ // Check for errors.
+ switch {
+ case pier.ctx.Err() != nil:
+ return pier.ctx.Err()
+ case err != nil:
+ return err
+ }
+
+ // Create new ship.
+ ship := &TCPShip{
+ ShipBase: ShipBase{
+ transport: pier.transport,
+ conn: conn,
+ mine: false,
+ secure: false,
+ },
+ }
+ ship.calculateLoadSize(nil, conn.RemoteAddr(), TCPHeaderMTUSize)
+ ship.initBase()
+
+ // Submit new docking request.
+ select {
+ case pier.dockingRequests <- ship:
+ case <-pier.ctx.Done():
+ return pier.ctx.Err()
+ }
+ }
+}
+
+// Abolish closes the underlying listener and cleans up any related resources.
+func (pier *TCPPier) Abolish() {
+ pier.cancelCtx()
+ pier.PierBase.Abolish()
+}
diff --git a/spn/ships/testship.go b/spn/ships/testship.go
new file mode 100644
index 00000000..6ec74b6e
--- /dev/null
+++ b/spn/ships/testship.go
@@ -0,0 +1,154 @@
+package ships
+
+import (
+ "net"
+
+ "github.com/mr-tron/base58"
+ "github.com/tevino/abool"
+
+ "github.com/safing/portmaster/spn/hub"
+)
+
+// TestShip is a simulated ship that is used for testing higher level components.
+type TestShip struct {
+ mine bool
+ secure bool
+ loadSize int
+ forward chan []byte
+ backward chan []byte
+ unloadTmp []byte
+ sinking *abool.AtomicBool
+}
+
+// NewTestShip returns a new TestShip for simulation.
+func NewTestShip(secure bool, loadSize int) *TestShip {
+ return &TestShip{
+ mine: true,
+ secure: secure,
+ loadSize: loadSize,
+ forward: make(chan []byte, 100),
+ backward: make(chan []byte, 100),
+ sinking: abool.NewBool(false),
+ }
+}
+
+// String returns a human readable informational summary about the ship.
+func (ship *TestShip) String() string {
+ if ship.mine {
+ return ""
+ }
+ return ""
+}
+
+// Transport returns the transport used for this ship.
+func (ship *TestShip) Transport() *hub.Transport {
+ return &hub.Transport{
+ Protocol: "dummy",
+ }
+}
+
+// IsMine returns whether the ship was launched from here.
+func (ship *TestShip) IsMine() bool {
+ return ship.mine
+}
+
+// IsSecure returns whether the ship provides transport security.
+func (ship *TestShip) IsSecure() bool {
+ return ship.secure
+}
+
+// LoadSize returns the recommended data size that should be handed to Load().
+// This value will be most likely somehow related to the connection's MTU.
+// Alternatively, using a multiple of LoadSize is also recommended.
+func (ship *TestShip) LoadSize() int {
+ return ship.loadSize
+}
+
+// Reverse creates a connected TestShip. This is used to simulate a connection instead of using a Pier.
+func (ship *TestShip) Reverse() *TestShip {
+ return &TestShip{
+ mine: !ship.mine,
+ secure: ship.secure,
+ loadSize: ship.loadSize,
+ forward: ship.backward,
+ backward: ship.forward,
+ sinking: abool.NewBool(false),
+ }
+}
+
+// Load loads data into the ship - ie. sends the data via the connection.
+// Returns ErrSunk if the ship has already sunk earlier.
+func (ship *TestShip) Load(data []byte) error {
+ // Debugging:
+ // log.Debugf("spn/ship: loading %s", spew.Sdump(data))
+
+ // Check if ship is alive.
+ if ship.sinking.IsSet() {
+ return ErrSunk
+ }
+
+ // Empty load is used as a signal to cease operaetion.
+ if len(data) == 0 {
+ ship.Sink()
+ return nil
+ }
+
+ // Send all given data.
+ ship.forward <- data
+
+ return nil
+}
+
+// UnloadTo unloads data from the ship - ie. receives data from the
+// connection - puts it into the buf. It returns the amount of data
+// written and an optional error.
+// Returns ErrSunk if the ship has already sunk earlier.
+func (ship *TestShip) UnloadTo(buf []byte) (n int, err error) {
+ // Process unload tmp data, if there is any.
+ if ship.unloadTmp != nil {
+ // Copy as much data as possible.
+ copy(buf, ship.unloadTmp)
+
+ // If buf was too small, skip the copied section.
+ if len(buf) < len(ship.unloadTmp) {
+ ship.unloadTmp = ship.unloadTmp[len(buf):]
+ return len(buf), nil
+ }
+
+ // If everything was copied, unset the unloadTmp data.
+ n := len(ship.unloadTmp)
+ ship.unloadTmp = nil
+ return n, nil
+ }
+
+ // Receive data.
+ data := <-ship.backward
+ if len(data) == 0 {
+ return 0, ErrSunk
+ }
+
+ // Copy data, possibly save remainder for later.
+ copy(buf, data)
+ if len(buf) < len(data) {
+ ship.unloadTmp = data[len(buf):]
+ return len(buf), nil
+ }
+ return len(data), nil
+}
+
+// Sink closes the underlying connection and cleans up any related resources.
+func (ship *TestShip) Sink() {
+ if ship.sinking.SetToIf(false, true) {
+ close(ship.forward)
+ }
+}
+
+// Dummy methods to conform to interface for testing.
+
+func (ship *TestShip) LocalAddr() net.Addr { return nil } //nolint:golint
+func (ship *TestShip) RemoteAddr() net.Addr { return nil } //nolint:golint
+func (ship *TestShip) Public() bool { return true } //nolint:golint
+func (ship *TestShip) MarkPublic() {} //nolint:golint
+func (ship *TestShip) MaskAddress(addr net.Addr) string { return addr.String() } //nolint:golint
+func (ship *TestShip) MaskIP(ip net.IP) string { return ip.String() } //nolint:golint
+func (ship *TestShip) Mask(value []byte) string { return base58.Encode(value) } //nolint:golint
diff --git a/spn/ships/testship_test.go b/spn/ships/testship_test.go
new file mode 100644
index 00000000..7e026b92
--- /dev/null
+++ b/spn/ships/testship_test.go
@@ -0,0 +1,58 @@
+package ships
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTestShip(t *testing.T) {
+ t.Parallel()
+
+ tShip := NewTestShip(true, 100)
+
+ // interface conformance test
+ var ship Ship = tShip
+
+ srvShip := tShip.Reverse()
+
+ for i := 0; i < 100; i++ {
+ // client send
+ err := ship.Load(testData)
+ if err != nil {
+ t.Fatalf("%s failed: %s", ship, err)
+ }
+
+ // server recv
+ buf := getTestBuf()
+ _, err = srvShip.UnloadTo(buf)
+ if err != nil {
+ t.Fatalf("%s failed: %s", ship, err)
+ }
+
+ // check data
+ assert.Equal(t, testData, buf, "should match")
+ fmt.Print(".")
+
+ // server send
+ err = srvShip.Load(testData)
+ if err != nil {
+ t.Fatalf("%s failed: %s", ship, err)
+ }
+
+ // client recv
+ buf = getTestBuf()
+ _, err = ship.UnloadTo(buf)
+ if err != nil {
+ t.Fatalf("%s failed: %s", ship, err)
+ }
+
+ // check data
+ assert.Equal(t, testData, buf, "should match")
+ fmt.Print(".")
+ }
+
+ ship.Sink()
+ srvShip.Sink()
+}
diff --git a/spn/ships/virtual_network.go b/spn/ships/virtual_network.go
new file mode 100644
index 00000000..314112ef
--- /dev/null
+++ b/spn/ships/virtual_network.go
@@ -0,0 +1,43 @@
+package ships
+
+import (
+ "net"
+ "sync"
+
+ "github.com/safing/portmaster/spn/hub"
+)
+
+var (
+ virtNetLock sync.Mutex
+ virtNetConfig *hub.VirtualNetworkConfig
+)
+
+// SetVirtualNetworkConfig sets the virtual networking config.
+func SetVirtualNetworkConfig(config *hub.VirtualNetworkConfig) {
+ virtNetLock.Lock()
+ defer virtNetLock.Unlock()
+
+ virtNetConfig = config
+}
+
+// GetVirtualNetworkConfig returns the virtual networking config.
+func GetVirtualNetworkConfig() *hub.VirtualNetworkConfig {
+ virtNetLock.Lock()
+ defer virtNetLock.Unlock()
+
+ return virtNetConfig
+}
+
+// GetVirtualNetworkAddress returns the virtual network IP for the given Hub.
+func GetVirtualNetworkAddress(dstHubID string) net.IP {
+ virtNetLock.Lock()
+ defer virtNetLock.Unlock()
+
+ // Check if we have a virtual network config.
+ if virtNetConfig == nil {
+ return nil
+ }
+
+ // Return mapping for given Hub ID.
+ return virtNetConfig.Mapping[dstHubID]
+}
diff --git a/spn/sluice/module.go b/spn/sluice/module.go
new file mode 100644
index 00000000..63f1d2e0
--- /dev/null
+++ b/spn/sluice/module.go
@@ -0,0 +1,46 @@
+package sluice
+
+import (
+ "github.com/safing/portbase/log"
+ "github.com/safing/portbase/modules"
+ "github.com/safing/portmaster/service/netenv"
+ "github.com/safing/portmaster/spn/conf"
+)
+
+var (
+ module *modules.Module
+
+ entrypointInfoMsg = []byte("You have reached the local SPN entry port, but your connection could not be matched to an SPN tunnel.\n")
+
+ // EnableListener indicates if it should start the sluice listeners. Must be set at startup.
+ EnableListener bool = true
+)
+
+func init() {
+ module = modules.Register("sluice", nil, start, stop, "terminal")
+}
+
+func start() error {
+ // TODO:
+ // Listening on all interfaces for now, as we need this for Windows.
+ // Handle similarly to the nameserver listener.
+
+ if conf.Client() && EnableListener {
+ StartSluice("tcp4", "0.0.0.0:717")
+ StartSluice("udp4", "0.0.0.0:717")
+
+ if netenv.IPv6Enabled() {
+ StartSluice("tcp6", "[::]:717")
+ StartSluice("udp6", "[::]:717")
+ } else {
+ log.Warningf("spn/sluice: no IPv6 stack detected, disabling IPv6 SPN entry endpoints")
+ }
+ }
+
+ return nil
+}
+
+func stop() error {
+ stopAllSluices()
+ return nil
+}
diff --git a/spn/sluice/packet_listener.go b/spn/sluice/packet_listener.go
new file mode 100644
index 00000000..3eb64cbb
--- /dev/null
+++ b/spn/sluice/packet_listener.go
@@ -0,0 +1,277 @@
+package sluice
+
+import (
+ "context"
+ "io"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tevino/abool"
+)
+
+// PacketListener is a listener for packet based protocols.
+type PacketListener struct {
+ sock net.PacketConn
+ closed *abool.AtomicBool
+ newConns chan *PacketConn
+
+ lock sync.Mutex
+ conns map[string]*PacketConn
+ err error
+}
+
+// ListenPacket creates a packet listener.
+func ListenPacket(network, address string) (net.Listener, error) {
+ // Create a new listening packet socket.
+ sock, err := net.ListenPacket(network, address)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create listener and start workers.
+ ln := &PacketListener{
+ sock: sock,
+ closed: abool.New(),
+ newConns: make(chan *PacketConn),
+ conns: make(map[string]*PacketConn),
+ }
+ module.StartServiceWorker("packet listener reader", 0, ln.reader)
+ module.StartServiceWorker("packet listener cleaner", time.Minute, ln.cleaner)
+
+ return ln, nil
+}
+
+// Accept waits for and returns the next connection to the listener.
+func (ln *PacketListener) Accept() (net.Conn, error) {
+ conn := <-ln.newConns
+ if conn != nil {
+ return conn, nil
+ }
+
+ // Check if there is a socket error.
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+ if ln.err != nil {
+ return nil, ln.err
+ }
+
+ return nil, io.EOF
+}
+
+// Close closes the listener.
+// Any blocked Accept operations will be unblocked and return errors.
+func (ln *PacketListener) Close() error {
+ if !ln.closed.SetToIf(false, true) {
+ return nil
+ }
+
+ // Close all channels.
+ close(ln.newConns)
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+ for _, conn := range ln.conns {
+ close(conn.in)
+ }
+
+ // Close socket.
+ return ln.sock.Close()
+}
+
+// Addr returns the listener's network address.
+func (ln *PacketListener) Addr() net.Addr {
+ return ln.sock.LocalAddr()
+}
+
+func (ln *PacketListener) getConn(remoteAddr string) (conn *PacketConn, ok bool) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ conn, ok = ln.conns[remoteAddr]
+ return
+}
+
+func (ln *PacketListener) setConn(conn *PacketConn) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ ln.conns[conn.addr.String()] = conn
+}
+
+func (ln *PacketListener) reader(_ context.Context) error {
+ for {
+ // Read data from connection.
+ buf := make([]byte, 512)
+ n, addr, err := ln.sock.ReadFrom(buf)
+ if err != nil {
+ // Set socket error.
+ ln.lock.Lock()
+ ln.err = err
+ ln.lock.Unlock()
+ // Close and return
+ _ = ln.Close()
+ return nil //nolint:nilerr
+ }
+ buf = buf[:n]
+
+ // Get connection and supply data.
+ conn, ok := ln.getConn(addr.String())
+ if ok {
+ // Ignore if conn is closed.
+ if conn.closed.IsSet() {
+ continue
+ }
+
+ select {
+ case conn.in <- buf:
+ default:
+ }
+ continue
+ }
+
+ // Or create a new connection.
+ conn = &PacketConn{
+ ln: ln,
+ addr: addr,
+ closed: abool.New(),
+ closing: make(chan struct{}),
+ buf: buf,
+ in: make(chan []byte, 1),
+ inactivityCnt: new(uint32),
+ }
+ ln.setConn(conn)
+ ln.newConns <- conn
+ }
+}
+
+func (ln *PacketListener) cleaner(ctx context.Context) error {
+ for {
+ select {
+ case <-time.After(1 * time.Minute):
+ // Check if listener has died.
+ if ln.closed.IsSet() {
+ return nil
+ }
+ // Clean connections.
+ ln.cleanInactiveConns(10)
+
+ case <-ctx.Done():
+ // Exit with module stop.
+ return nil
+ }
+ }
+}
+
+func (ln *PacketListener) cleanInactiveConns(overInactivityCnt uint32) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ for k, conn := range ln.conns {
+ cnt := atomic.AddUint32(conn.inactivityCnt, 1)
+ switch {
+ case cnt > overInactivityCnt*2:
+ delete(ln.conns, k)
+ case cnt > overInactivityCnt:
+ _ = conn.Close()
+ }
+ }
+}
+
+// PacketConn simulates a connection for a stateless protocol.
+type PacketConn struct {
+ ln *PacketListener
+ addr net.Addr
+ closed *abool.AtomicBool
+ closing chan struct{}
+
+ buf []byte
+ in chan []byte
+
+ inactivityCnt *uint32
+}
+
+// Read reads data from the connection.
+// Read can be made to time out and return an error after a fixed
+// time limit; see SetDeadline and SetReadDeadline.
+func (conn *PacketConn) Read(b []byte) (n int, err error) {
+ // Check if connection is closed.
+ if conn.closed.IsSet() {
+ return 0, io.EOF
+ }
+
+ // Mark as active.
+ atomic.StoreUint32(conn.inactivityCnt, 0)
+
+ // Get new buffer.
+ if conn.buf == nil {
+ select {
+ case conn.buf = <-conn.in:
+ if conn.buf == nil {
+ return 0, io.EOF
+ }
+ case <-conn.closing:
+ return 0, io.EOF
+ }
+ }
+
+ // Serve from buffer.
+ copy(b, conn.buf)
+ if len(b) >= len(conn.buf) {
+ copied := len(conn.buf)
+ conn.buf = nil
+ return copied, nil
+ }
+ copied := len(b)
+ conn.buf = conn.buf[copied:]
+ return copied, nil
+}
+
+// Write writes data to the connection.
+// Write can be made to time out and return an error after a fixed
+// time limit; see SetDeadline and SetWriteDeadline.
+func (conn *PacketConn) Write(b []byte) (n int, err error) {
+ // Check if connection is closed.
+ if conn.closed.IsSet() {
+ return 0, io.EOF
+ }
+
+ // Mark as active.
+ atomic.StoreUint32(conn.inactivityCnt, 0)
+
+ return conn.ln.sock.WriteTo(b, conn.addr)
+}
+
+// Close is a no-op as UDP connections share a single socket. Just stop sending
+// packets without closing.
+func (conn *PacketConn) Close() error {
+ if conn.closed.SetToIf(false, true) {
+ close(conn.closing)
+ }
+ return nil
+}
+
+// LocalAddr returns the local network address.
+func (conn *PacketConn) LocalAddr() net.Addr {
+ return conn.ln.sock.LocalAddr()
+}
+
+// RemoteAddr returns the remote network address.
+func (conn *PacketConn) RemoteAddr() net.Addr {
+ return conn.addr
+}
+
+// SetDeadline is a no-op as UDP connections share a single socket.
+func (conn *PacketConn) SetDeadline(t time.Time) error {
+ return nil
+}
+
+// SetReadDeadline is a no-op as UDP connections share a single socket.
+func (conn *PacketConn) SetReadDeadline(t time.Time) error {
+ return nil
+}
+
+// SetWriteDeadline is a no-op as UDP connections share a single socket.
+func (conn *PacketConn) SetWriteDeadline(t time.Time) error {
+ return nil
+}
diff --git a/spn/sluice/request.go b/spn/sluice/request.go
new file mode 100644
index 00000000..2347ed35
--- /dev/null
+++ b/spn/sluice/request.go
@@ -0,0 +1,78 @@
+package sluice
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/safing/portmaster/service/network"
+ "github.com/safing/portmaster/service/network/packet"
+)
+
+const (
+ defaultSluiceTTL = 30 * time.Second
+)
+
+var (
+ // ErrUnsupported is returned when a protocol is not supported.
+ ErrUnsupported = errors.New("unsupported protocol")
+
+ // ErrSluiceOffline is returned when the sluice for a network is offline.
+ ErrSluiceOffline = errors.New("is offline")
+)
+
+// Request holds request data for a sluice entry.
+type Request struct {
+ ConnInfo *network.Connection
+ CallbackFn RequestCallbackFunc
+ Expires time.Time
+}
+
+// RequestCallbackFunc is called for taking a over handling connection that arrived at the sluice.
+type RequestCallbackFunc func(connInfo *network.Connection, conn net.Conn)
+
+// AwaitRequest pre-registers a connection at the sluice for initializing it when it arrives.
+func AwaitRequest(connInfo *network.Connection, callbackFn RequestCallbackFunc) error {
+ network := getNetworkFromConnInfo(connInfo)
+ if network == "" {
+ return ErrUnsupported
+ }
+
+ sluice, ok := getSluice(network)
+ if !ok {
+ return fmt.Errorf("sluice for network %s %w", network, ErrSluiceOffline)
+ }
+
+ return sluice.AwaitRequest(&Request{
+ ConnInfo: connInfo,
+ CallbackFn: callbackFn,
+ Expires: time.Now().Add(defaultSluiceTTL),
+ })
+}
+
+func getNetworkFromConnInfo(connInfo *network.Connection) string {
+ var network string
+
+ // protocol
+ switch connInfo.IPProtocol { //nolint:exhaustive // Looking for specific values.
+ case packet.TCP:
+ network = "tcp"
+ case packet.UDP:
+ network = "udp"
+ default:
+ return ""
+ }
+
+ // IP version
+ switch connInfo.IPVersion {
+ case packet.IPv4:
+ network += "4"
+ case packet.IPv6:
+ network += "6"
+ default:
+ return ""
+ }
+
+ return network
+}
diff --git a/spn/sluice/sluice.go b/spn/sluice/sluice.go
new file mode 100644
index 00000000..32a33151
--- /dev/null
+++ b/spn/sluice/sluice.go
@@ -0,0 +1,229 @@
+package sluice
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/service/netenv"
+)
+
+// Sluice is a tunnel entry listener.
+type Sluice struct {
+ network string
+ address string
+ createListener ListenerFactory
+
+ lock sync.Mutex
+ listener net.Listener
+ pendingRequests map[string]*Request
+ abandoned bool
+}
+
+// ListenerFactory defines a function to create a listener.
+type ListenerFactory func(network, address string) (net.Listener, error)
+
+// StartSluice starts a sluice listener at the given address.
+func StartSluice(network, address string) {
+ s := &Sluice{
+ network: network,
+ address: address,
+ pendingRequests: make(map[string]*Request),
+ }
+
+ switch s.network {
+ case "tcp4", "tcp6":
+ s.createListener = net.Listen
+ case "udp4", "udp6":
+ s.createListener = ListenUDP
+ default:
+ log.Errorf("spn/sluice: cannot start sluice for %s: unsupported network", network)
+ return
+ }
+
+ // Start service worker.
+ module.StartServiceWorker(
+ fmt.Sprintf("%s sluice listener", s.network),
+ 10*time.Second,
+ s.listenHandler,
+ )
+}
+
+// AwaitRequest pre-registers a connection.
+func (s *Sluice) AwaitRequest(r *Request) error {
+ // Set default expiry.
+ if r.Expires.IsZero() {
+ r.Expires = time.Now().Add(defaultSluiceTTL)
+ }
+
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ // Check if a pending request already exists for this local address.
+ key := net.JoinHostPort(r.ConnInfo.LocalIP.String(), strconv.Itoa(int(r.ConnInfo.LocalPort)))
+ _, exists := s.pendingRequests[key]
+ if exists {
+ return fmt.Errorf("a pending request for %s already exists", key)
+ }
+
+ // Add to pending requests.
+ s.pendingRequests[key] = r
+ return nil
+}
+
+func (s *Sluice) getRequest(address string) (r *Request, ok bool) {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ r, ok = s.pendingRequests[address]
+ if ok {
+ delete(s.pendingRequests, address)
+ }
+ return
+}
+
+func (s *Sluice) init() error {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ s.abandoned = false
+
+ // start listening
+ s.listener = nil
+ ln, err := s.createListener(s.network, s.address)
+ if err != nil {
+ return fmt.Errorf("failed to listen: %w", err)
+ }
+ s.listener = ln
+
+ // Add to registry.
+ addSluice(s)
+
+ return nil
+}
+
+func (s *Sluice) abandon() {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ if s.abandoned {
+ return
+ }
+ s.abandoned = true
+
+ // Remove from registry.
+ removeSluice(s.network)
+
+ // Close listener.
+ if s.listener != nil {
+ _ = s.listener.Close()
+ }
+
+ // Notify pending requests.
+ for i, r := range s.pendingRequests {
+ r.CallbackFn(r.ConnInfo, nil)
+ delete(s.pendingRequests, i)
+ }
+}
+
+func (s *Sluice) handleConnection(conn net.Conn) {
+ // Close the connection if handling is not successful.
+ success := false
+ defer func() {
+ if !success {
+ _ = conn.Close()
+ }
+ }()
+
+ // Get IP address.
+ var remoteIP net.IP
+ switch typedAddr := conn.RemoteAddr().(type) {
+ case *net.TCPAddr:
+ remoteIP = typedAddr.IP
+ case *net.UDPAddr:
+ remoteIP = typedAddr.IP
+ default:
+ log.Warningf("spn/sluice: cannot handle connection for unsupported network %s", conn.RemoteAddr().Network())
+ return
+ }
+
+ // Check if the request is local.
+ local, err := netenv.IsMyIP(remoteIP)
+ if err != nil {
+ log.Warningf("spn/sluice: failed to check if request from %s is local: %s", remoteIP, err)
+ return
+ }
+ if !local {
+ log.Warningf("spn/sluice: received external request from %s, ignoring", remoteIP)
+
+ // TODO:
+ // Do not allow this to be spammed.
+ // Only allow one trigger per second.
+ // Do not trigger by same "remote IP" in a row.
+ netenv.TriggerNetworkChangeCheck()
+
+ return
+ }
+
+ // Get waiting request.
+ r, ok := s.getRequest(conn.RemoteAddr().String())
+ if !ok {
+ _, err := conn.Write(entrypointInfoMsg)
+ if err != nil {
+ log.Warningf("spn/sluice: new %s request from %s without pending request, but failed to reply with info msg: %s", s.network, conn.RemoteAddr(), err)
+ } else {
+ log.Debugf("spn/sluice: new %s request from %s without pending request, replied with info msg", s.network, conn.RemoteAddr())
+ }
+ return
+ }
+
+ // Hand over to callback.
+ log.Tracef(
+ "spn/sluice: new %s request from %s for %s (%s:%d)",
+ s.network, conn.RemoteAddr(),
+ r.ConnInfo.Entity.Domain, r.ConnInfo.Entity.IP, r.ConnInfo.Entity.Port,
+ )
+ r.CallbackFn(r.ConnInfo, conn)
+ success = true
+}
+
+func (s *Sluice) listenHandler(_ context.Context) error {
+ defer s.abandon()
+ err := s.init()
+ if err != nil {
+ return err
+ }
+
+ // Handle new connections.
+ log.Infof("spn/sluice: started listening for %s requests on %s", s.network, s.listener.Addr())
+ for {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ if module.IsStopping() {
+ return nil
+ }
+ return fmt.Errorf("failed to accept connection: %w", err)
+ }
+
+ // Handle accepted connection.
+ s.handleConnection(conn)
+
+ // Clean up old leftovers.
+ s.cleanConnections()
+ }
+}
+
+func (s *Sluice) cleanConnections() {
+ s.lock.Lock()
+ defer s.lock.Unlock()
+
+ now := time.Now()
+ for address, request := range s.pendingRequests {
+ if now.After(request.Expires) {
+ delete(s.pendingRequests, address)
+ log.Debugf("spn/sluice: removed expired pending %s connection %s", s.network, request.ConnInfo)
+ }
+ }
+}
diff --git a/spn/sluice/sluices.go b/spn/sluice/sluices.go
new file mode 100644
index 00000000..1ae58777
--- /dev/null
+++ b/spn/sluice/sluices.go
@@ -0,0 +1,47 @@
+package sluice
+
+import "sync"
+
+var (
+ sluices = make(map[string]*Sluice)
+ sluicesLock sync.RWMutex
+)
+
+func getSluice(network string) (s *Sluice, ok bool) {
+ sluicesLock.RLock()
+ defer sluicesLock.RUnlock()
+
+ s, ok = sluices[network]
+ return
+}
+
+func addSluice(s *Sluice) {
+ sluicesLock.Lock()
+ defer sluicesLock.Unlock()
+
+ sluices[s.network] = s
+}
+
+func removeSluice(network string) {
+ sluicesLock.Lock()
+ defer sluicesLock.Unlock()
+
+ delete(sluices, network)
+}
+
+func copySluices() map[string]*Sluice {
+ sluicesLock.Lock()
+ defer sluicesLock.Unlock()
+
+ copied := make(map[string]*Sluice, len(sluices))
+ for k, v := range sluices {
+ copied[k] = v
+ }
+ return copied
+}
+
+func stopAllSluices() {
+ for _, sluice := range copySluices() {
+ sluice.abandon()
+ }
+}
diff --git a/spn/sluice/udp_listener.go b/spn/sluice/udp_listener.go
new file mode 100644
index 00000000..4065d520
--- /dev/null
+++ b/spn/sluice/udp_listener.go
@@ -0,0 +1,334 @@
+package sluice
+
+import (
+ "context"
+ "io"
+ "net"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tevino/abool"
+ "golang.org/x/net/ipv4"
+ "golang.org/x/net/ipv6"
+)
+
+const onWindows = runtime.GOOS == "windows"
+
+// UDPListener is a listener for UDP.
+type UDPListener struct {
+ sock *net.UDPConn
+ closed *abool.AtomicBool
+ newConns chan *UDPConn
+ oobSize int
+
+ lock sync.Mutex
+ conns map[string]*UDPConn
+ err error
+}
+
+// ListenUDP creates a packet listener.
+func ListenUDP(network, address string) (net.Listener, error) {
+ // Parse address.
+ udpAddr, err := net.ResolveUDPAddr(network, address)
+ if err != nil {
+ return nil, err
+ }
+
+ // Determine oob data size.
+ oobSize := 40 // IPv6 (measured)
+ if udpAddr.IP.To4() != nil {
+ oobSize = 32 // IPv4 (measured)
+ }
+
+ // Create a new listening UDP socket.
+ sock, err := net.ListenUDP(network, udpAddr)
+ if err != nil {
+ return nil, err
+ }
+
+ // Create listener.
+ ln := &UDPListener{
+ sock: sock,
+ closed: abool.New(),
+ newConns: make(chan *UDPConn),
+ oobSize: oobSize,
+ conns: make(map[string]*UDPConn),
+ }
+
+ // Set socket options on listener.
+ err = ln.setSocketOptions()
+ if err != nil {
+ return nil, err
+ }
+
+ // Start workers.
+ module.StartServiceWorker("udp listener reader", 0, ln.reader)
+ module.StartServiceWorker("udp listener cleaner", time.Minute, ln.cleaner)
+
+ return ln, nil
+}
+
+// Accept waits for and returns the next connection to the listener.
+func (ln *UDPListener) Accept() (net.Conn, error) {
+ conn := <-ln.newConns
+ if conn != nil {
+ return conn, nil
+ }
+
+ // Check if there is a socket error.
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+ if ln.err != nil {
+ return nil, ln.err
+ }
+
+ return nil, io.EOF
+}
+
+// Close closes the listener.
+// Any blocked Accept operations will be unblocked and return errors.
+func (ln *UDPListener) Close() error {
+ if !ln.closed.SetToIf(false, true) {
+ return nil
+ }
+
+ // Close all channels.
+ close(ln.newConns)
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+ for _, conn := range ln.conns {
+ close(conn.in)
+ }
+
+ // Close socket.
+ return ln.sock.Close()
+}
+
+// Addr returns the listener's network address.
+func (ln *UDPListener) Addr() net.Addr {
+ return ln.sock.LocalAddr()
+}
+
+func (ln *UDPListener) getConn(remoteAddr string) (conn *UDPConn, ok bool) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ conn, ok = ln.conns[remoteAddr]
+ return
+}
+
+func (ln *UDPListener) setConn(conn *UDPConn) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ ln.conns[conn.addr.String()] = conn
+}
+
+func (ln *UDPListener) reader(_ context.Context) error {
+ for {
+ // TODO: Find good buf size.
+ // With a buf size of 512 we have seen this error on Windows:
+ // wsarecvmsg: A message sent on a datagram socket was larger than the internal message buffer or some other network limit, or the buffer used to receive a datagram into was smaller than the datagram itself.
+ // UDP is not (yet) heavily used, so we can go for the 1500 bytes size for now.
+
+ // Read data from connection.
+ buf := make([]byte, 1500) // TODO: see comment above.
+ oob := make([]byte, ln.oobSize)
+ n, oobn, _, addr, err := ln.sock.ReadMsgUDP(buf, oob)
+ if err != nil {
+ // Set socket error.
+ ln.lock.Lock()
+ ln.err = err
+ ln.lock.Unlock()
+ // Close and return
+ _ = ln.Close()
+ return nil //nolint:nilerr
+ }
+ buf = buf[:n]
+ oob = oob[:oobn]
+
+ // Get connection and supply data.
+ conn, ok := ln.getConn(addr.String())
+ if ok {
+ // Ignore if conn is closed.
+ if conn.closed.IsSet() {
+ continue
+ }
+
+ select {
+ case conn.in <- buf:
+ default:
+ }
+ continue
+ }
+
+ // Or create a new connection.
+ conn = &UDPConn{
+ ln: ln,
+ addr: addr,
+ oob: oob,
+ closed: abool.New(),
+ closing: make(chan struct{}),
+ buf: buf,
+ in: make(chan []byte, 1),
+ inactivityCnt: new(uint32),
+ }
+ ln.setConn(conn)
+ ln.newConns <- conn
+ }
+}
+
+func (ln *UDPListener) cleaner(ctx context.Context) error {
+ for {
+ select {
+ case <-time.After(1 * time.Minute):
+ // Check if listener has died.
+ if ln.closed.IsSet() {
+ return nil
+ }
+ // Clean connections.
+ ln.cleanInactiveConns(10)
+
+ case <-ctx.Done():
+ // Exit with module stop.
+ return nil
+ }
+ }
+}
+
+func (ln *UDPListener) cleanInactiveConns(overInactivityCnt uint32) {
+ ln.lock.Lock()
+ defer ln.lock.Unlock()
+
+ for k, conn := range ln.conns {
+ cnt := atomic.AddUint32(conn.inactivityCnt, 1)
+ switch {
+ case cnt > overInactivityCnt*2:
+ delete(ln.conns, k)
+ case cnt > overInactivityCnt:
+ _ = conn.Close()
+ }
+ }
+}
+
+// setUDPSocketOptions sets socket options so that the source address for
+// replies is correct.
+func (ln *UDPListener) setSocketOptions() error {
+ // Setting socket options is not supported on windows.
+ if onWindows {
+ return nil
+ }
+
+ // As we might be listening on an interface that supports both IPv4 and IPv6,
+ // try to set the socket options on both.
+ // Only report an error if it fails on both.
+ err4 := ipv4.NewPacketConn(ln.sock).SetControlMessage(ipv4.FlagDst|ipv4.FlagInterface, true)
+ err6 := ipv6.NewPacketConn(ln.sock).SetControlMessage(ipv6.FlagDst|ipv6.FlagInterface, true)
+ if err4 != nil && err6 != nil {
+ return err4
+ }
+
+ return nil
+}
+
+// UDPConn simulates a connection for a stateless protocol.
+type UDPConn struct {
+ ln *UDPListener
+ addr *net.UDPAddr
+ oob []byte
+ closed *abool.AtomicBool
+ closing chan struct{}
+
+ buf []byte
+ in chan []byte
+
+ inactivityCnt *uint32
+}
+
+// Read reads data from the connection.
+// Read can be made to time out and return an error after a fixed
+// time limit; see SetDeadline and SetReadDeadline.
+func (conn *UDPConn) Read(b []byte) (n int, err error) {
+ // Check if connection is closed.
+ if conn.closed.IsSet() {
+ return 0, io.EOF
+ }
+
+ // Mark as active.
+ atomic.StoreUint32(conn.inactivityCnt, 0)
+
+ // Get new buffer.
+ if conn.buf == nil {
+ select {
+ case conn.buf = <-conn.in:
+ if conn.buf == nil {
+ return 0, io.EOF
+ }
+ case <-conn.closing:
+ return 0, io.EOF
+ }
+ }
+
+ // Serve from buffer.
+ copy(b, conn.buf)
+ if len(b) >= len(conn.buf) {
+ copied := len(conn.buf)
+ conn.buf = nil
+ return copied, nil
+ }
+ copied := len(b)
+ conn.buf = conn.buf[copied:]
+ return copied, nil
+}
+
+// Write writes data to the connection.
+// Write can be made to time out and return an error after a fixed
+// time limit; see SetDeadline and SetWriteDeadline.
+func (conn *UDPConn) Write(b []byte) (n int, err error) {
+ // Check if connection is closed.
+ if conn.closed.IsSet() {
+ return 0, io.EOF
+ }
+
+ // Mark as active.
+ atomic.StoreUint32(conn.inactivityCnt, 0)
+
+ n, _, err = conn.ln.sock.WriteMsgUDP(b, conn.oob, conn.addr)
+ return n, err
+}
+
+// Close is a no-op as UDP connections share a single socket. Just stop sending
+// packets without closing.
+func (conn *UDPConn) Close() error {
+ if conn.closed.SetToIf(false, true) {
+ close(conn.closing)
+ }
+ return nil
+}
+
+// LocalAddr returns the local network address.
+func (conn *UDPConn) LocalAddr() net.Addr {
+ return conn.ln.sock.LocalAddr()
+}
+
+// RemoteAddr returns the remote network address.
+func (conn *UDPConn) RemoteAddr() net.Addr {
+ return conn.addr
+}
+
+// SetDeadline is a no-op as UDP connections share a single socket.
+func (conn *UDPConn) SetDeadline(t time.Time) error {
+ return nil
+}
+
+// SetReadDeadline is a no-op as UDP connections share a single socket.
+func (conn *UDPConn) SetReadDeadline(t time.Time) error {
+ return nil
+}
+
+// SetWriteDeadline is a no-op as UDP connections share a single socket.
+func (conn *UDPConn) SetWriteDeadline(t time.Time) error {
+ return nil
+}
diff --git a/spn/spn.go b/spn/spn.go
new file mode 100644
index 00000000..569d85de
--- /dev/null
+++ b/spn/spn.go
@@ -0,0 +1 @@
+package spn
diff --git a/spn/terminal/control_flow.go b/spn/terminal/control_flow.go
new file mode 100644
index 00000000..e4d15ccf
--- /dev/null
+++ b/spn/terminal/control_flow.go
@@ -0,0 +1,454 @@
+package terminal
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/safing/portbase/formats/varint"
+ "github.com/safing/portbase/modules"
+)
+
+// FlowControl defines the flow control interface.
+type FlowControl interface {
+ Deliver(msg *Msg) *Error
+ Receive() <-chan *Msg
+ Send(msg *Msg, timeout time.Duration) *Error
+ ReadyToSend() <-chan struct{}
+ Flush(timeout time.Duration)
+ StartWorkers(m *modules.Module, terminalName string)
+ RecvQueueLen() int
+ SendQueueLen() int
+}
+
+// FlowControlType represents a flow control type.
+type FlowControlType uint8
+
+// Flow Control Types.
+const (
+ FlowControlDefault FlowControlType = 0
+ FlowControlDFQ FlowControlType = 1
+ FlowControlNone FlowControlType = 2
+
+ defaultFlowControl = FlowControlDFQ
+)
+
+// DefaultSize returns the default flow control size.
+func (fct FlowControlType) DefaultSize() uint32 {
+ if fct == FlowControlDefault {
+ fct = defaultFlowControl
+ }
+
+ switch fct {
+ case FlowControlDFQ:
+ return 50000
+ case FlowControlNone:
+ return 10000
+ case FlowControlDefault:
+ fallthrough
+ default:
+ return 0
+ }
+}
+
+// Flow Queue Configuration.
+const (
+ DefaultQueueSize = 50000
+ MaxQueueSize = 1000000
+ forceReportBelowPercent = 0.75
+)
+
+// DuplexFlowQueue is a duplex flow control mechanism using queues.
+type DuplexFlowQueue struct {
+ // ti is the Terminal that is using the DFQ.
+ ctx context.Context
+
+ // submitUpstream is used to submit messages to the upstream channel.
+ submitUpstream func(msg *Msg, timeout time.Duration)
+
+ // sendQueue holds the messages that are waiting to be sent.
+ sendQueue chan *Msg
+ // prioMsgs holds the number of messages to send with high priority.
+ prioMsgs *int32
+ // sendSpace indicates the amount free slots in the recvQueue on the other end.
+ sendSpace *int32
+ // readyToSend is used to notify sending components that there is free space.
+ readyToSend chan struct{}
+ // wakeSender is used to wake a sender in case the sendSpace was zero and the
+ // sender is waiting for available space.
+ wakeSender chan struct{}
+
+ // recvQueue holds the messages that are waiting to be processed.
+ recvQueue chan *Msg
+ // reportedSpace indicates the amount of free slots that the other end knows
+ // about.
+ reportedSpace *int32
+ // spaceReportLock locks the calculation of space to report.
+ spaceReportLock sync.Mutex
+ // forceSpaceReport forces the sender to send a space report.
+ forceSpaceReport chan struct{}
+
+ // flush is used to send a finish function to the handler, which will write
+ // all pending messages and then call the received function.
+ flush chan func()
+}
+
+// NewDuplexFlowQueue returns a new duplex flow queue.
+func NewDuplexFlowQueue(
+ ctx context.Context,
+ queueSize uint32,
+ submitUpstream func(msg *Msg, timeout time.Duration),
+) *DuplexFlowQueue {
+ dfq := &DuplexFlowQueue{
+ ctx: ctx,
+ submitUpstream: submitUpstream,
+ sendQueue: make(chan *Msg, queueSize),
+ prioMsgs: new(int32),
+ sendSpace: new(int32),
+ readyToSend: make(chan struct{}),
+ wakeSender: make(chan struct{}, 1),
+ recvQueue: make(chan *Msg, queueSize),
+ reportedSpace: new(int32),
+ forceSpaceReport: make(chan struct{}, 1),
+ flush: make(chan func()),
+ }
+ atomic.StoreInt32(dfq.sendSpace, int32(queueSize))
+ atomic.StoreInt32(dfq.reportedSpace, int32(queueSize))
+
+ return dfq
+}
+
+// StartWorkers starts the necessary workers to operate the flow queue.
+func (dfq *DuplexFlowQueue) StartWorkers(m *modules.Module, terminalName string) {
+ m.StartWorker(terminalName+" flow queue", dfq.FlowHandler)
+}
+
+// shouldReportRecvSpace returns whether the receive space should be reported.
+func (dfq *DuplexFlowQueue) shouldReportRecvSpace() bool {
+ return atomic.LoadInt32(dfq.reportedSpace) < int32(float32(cap(dfq.recvQueue))*forceReportBelowPercent)
+}
+
+// decrementReportedRecvSpace decreases the reported recv space by 1 and
+// returns if the receive space should be reported.
+func (dfq *DuplexFlowQueue) decrementReportedRecvSpace() (shouldReportRecvSpace bool) {
+ return atomic.AddInt32(dfq.reportedSpace, -1) < int32(float32(cap(dfq.recvQueue))*forceReportBelowPercent)
+}
+
+// getSendSpace returns the current send space.
+func (dfq *DuplexFlowQueue) getSendSpace() int32 {
+ return atomic.LoadInt32(dfq.sendSpace)
+}
+
+// decrementSendSpace decreases the send space by 1 and returns it.
+func (dfq *DuplexFlowQueue) decrementSendSpace() int32 {
+ return atomic.AddInt32(dfq.sendSpace, -1)
+}
+
+func (dfq *DuplexFlowQueue) addToSendSpace(n int32) {
+ // Add new space to send space and check if it was zero.
+ atomic.AddInt32(dfq.sendSpace, n)
+ // Wake the sender in case it is waiting.
+ select {
+ case dfq.wakeSender <- struct{}{}:
+ default:
+ }
+}
+
+// reportableRecvSpace returns how much free space can be reported to the other
+// end. The returned number must be communicated to the other end and must not
+// be ignored.
+func (dfq *DuplexFlowQueue) reportableRecvSpace() int32 {
+ // Changes to the recvQueue during calculation are no problem.
+ // We don't want to report space twice though!
+ dfq.spaceReportLock.Lock()
+ defer dfq.spaceReportLock.Unlock()
+
+ // Calculate reportable receive space and add it to the reported space.
+ reportedSpace := atomic.LoadInt32(dfq.reportedSpace)
+ toReport := int32(cap(dfq.recvQueue)-len(dfq.recvQueue)) - reportedSpace
+
+ // Never report values below zero.
+ // This can happen, as dfq.reportedSpace is decreased after a container is
+ // submitted to dfq.recvQueue by dfq.Deliver(). This race condition can only
+ // lower the space to report, not increase it. A simple check here solved
+ // this problem and keeps performance high.
+ // Also, don't report values of 1, as the benefit is minimal and this might
+ // be commonly triggered due to the buffer of the force report channel.
+ if toReport <= 1 {
+ return 0
+ }
+
+ // Add space to report to dfq.reportedSpace and return it.
+ atomic.AddInt32(dfq.reportedSpace, toReport)
+ return toReport
+}
+
+// FlowHandler handles all flow queue internals and must be started as a worker
+// in the module where it is used.
+func (dfq *DuplexFlowQueue) FlowHandler(_ context.Context) error {
+ // The upstreamSender is started by the terminal module, but is tied to the
+ // flow owner instead. Make sure that the flow owner's module depends on the
+ // terminal module so that it is shut down earlier.
+
+ var sendSpaceDepleted bool
+ var flushFinished func()
+
+ // Drain all queues when shutting down.
+ defer func() {
+ for {
+ select {
+ case msg := <-dfq.sendQueue:
+ msg.Finish()
+ case msg := <-dfq.recvQueue:
+ msg.Finish()
+ default:
+ return
+ }
+ }
+ }()
+
+sending:
+ for {
+ // If the send queue is depleted, wait to be woken.
+ if sendSpaceDepleted {
+ select {
+ case <-dfq.wakeSender:
+ if dfq.getSendSpace() > 0 {
+ sendSpaceDepleted = false
+ } else {
+ continue sending
+ }
+
+ case <-dfq.forceSpaceReport:
+ // Forced reporting of space.
+ // We do not need to check if there is enough sending space, as there is
+ // no data included.
+ spaceToReport := dfq.reportableRecvSpace()
+ if spaceToReport > 0 {
+ msg := NewMsg(varint.Pack64(uint64(spaceToReport)))
+ dfq.submitUpstream(msg, 0)
+ }
+ continue sending
+
+ case <-dfq.ctx.Done():
+ return nil
+ }
+ }
+
+ // Get message from send queue.
+
+ select {
+ case dfq.readyToSend <- struct{}{}:
+ // Notify that we are ready to send.
+
+ case msg := <-dfq.sendQueue:
+ // Send message from queue.
+
+ // If nil, the queue is being shut down.
+ if msg == nil {
+ return nil
+ }
+
+ // Check if we are handling a high priority message or waiting for one.
+ // Mark any msgs as high priority, when there is one in the pipeline.
+ remainingPrioMsgs := atomic.AddInt32(dfq.prioMsgs, -1)
+ switch {
+ case remainingPrioMsgs >= 0:
+ msg.Unit.MakeHighPriority()
+ case remainingPrioMsgs < -30_000:
+ // Prevent wrap to positive.
+ // Compatible with int16 or bigger.
+ atomic.StoreInt32(dfq.prioMsgs, 0)
+ }
+
+ // Wait for processing slot.
+ msg.Unit.WaitForSlot()
+
+ // Prepend available receiving space.
+ msg.Data.Prepend(varint.Pack64(uint64(dfq.reportableRecvSpace())))
+
+ // Submit for sending upstream.
+ dfq.submitUpstream(msg, 0)
+ // Decrease the send space and set flag if depleted.
+ if dfq.decrementSendSpace() <= 0 {
+ sendSpaceDepleted = true
+ }
+
+ // Check if the send queue is empty now and signal flushers.
+ if flushFinished != nil && len(dfq.sendQueue) == 0 {
+ flushFinished()
+ flushFinished = nil
+ }
+
+ case <-dfq.forceSpaceReport:
+ // Forced reporting of space.
+ // We do not need to check if there is enough sending space, as there is
+ // no data included.
+ spaceToReport := dfq.reportableRecvSpace()
+ if spaceToReport > 0 {
+ msg := NewMsg(varint.Pack64(uint64(spaceToReport)))
+ dfq.submitUpstream(msg, 0)
+ }
+
+ case newFlushFinishedFn := <-dfq.flush:
+ // Signal immediately if send queue is empty.
+ if len(dfq.sendQueue) == 0 {
+ newFlushFinishedFn()
+ } else {
+ // If there already is a flush finished function, stack them.
+ if flushFinished != nil {
+ stackedFlushFinishFn := flushFinished
+ flushFinished = func() {
+ stackedFlushFinishFn()
+ newFlushFinishedFn()
+ }
+ } else {
+ flushFinished = newFlushFinishedFn
+ }
+ }
+
+ case <-dfq.ctx.Done():
+ return nil
+ }
+ }
+}
+
+// Flush waits for all waiting data to be sent.
+func (dfq *DuplexFlowQueue) Flush(timeout time.Duration) {
+ // Create channel and function for notifying.
+ wait := make(chan struct{})
+ finished := func() {
+ close(wait)
+ }
+ // Request flush and return when stopping.
+ select {
+ case dfq.flush <- finished:
+ case <-dfq.ctx.Done():
+ return
+ case <-TimedOut(timeout):
+ return
+ }
+ // Wait for flush to finish and return when stopping.
+ select {
+ case <-wait:
+ case <-dfq.ctx.Done():
+ case <-TimedOut(timeout):
+ }
+}
+
+var ready = make(chan struct{})
+
+func init() {
+ close(ready)
+}
+
+// ReadyToSend returns a channel that can be read when data can be sent.
+func (dfq *DuplexFlowQueue) ReadyToSend() <-chan struct{} {
+ if atomic.LoadInt32(dfq.sendSpace) > 0 {
+ return ready
+ }
+ return dfq.readyToSend
+}
+
+// Send adds the given container to the send queue.
+func (dfq *DuplexFlowQueue) Send(msg *Msg, timeout time.Duration) *Error {
+ select {
+ case dfq.sendQueue <- msg:
+ if msg.Unit.IsHighPriority() {
+ // Reset prioMsgs to the current queue size, so that all waiting and the
+ // message we just added are all handled as high priority.
+ atomic.StoreInt32(dfq.prioMsgs, int32(len(dfq.sendQueue)))
+ }
+ return nil
+
+ case <-TimedOut(timeout):
+ msg.Finish()
+ return ErrTimeout
+
+ case <-dfq.ctx.Done():
+ msg.Finish()
+ return ErrStopping
+ }
+}
+
+// Receive receives a container from the recv queue.
+func (dfq *DuplexFlowQueue) Receive() <-chan *Msg {
+ // If the reported recv space is nearing its end, force a report.
+ if dfq.shouldReportRecvSpace() {
+ select {
+ case dfq.forceSpaceReport <- struct{}{}:
+ default:
+ }
+ }
+
+ return dfq.recvQueue
+}
+
+// Deliver submits a container for receiving from upstream.
+func (dfq *DuplexFlowQueue) Deliver(msg *Msg) *Error {
+ // Ignore nil containers.
+ if msg == nil || msg.Data == nil {
+ msg.Finish()
+ return ErrMalformedData.With("no data")
+ }
+
+ // Get and add new reported space.
+ addSpace, err := msg.Data.GetNextN16()
+ if err != nil {
+ msg.Finish()
+ return ErrMalformedData.With("failed to parse reported space: %w", err)
+ }
+ if addSpace > 0 {
+ dfq.addToSendSpace(int32(addSpace))
+ }
+ // Abort processing if the container only contained a space update.
+ if !msg.Data.HoldsData() {
+ msg.Finish()
+ return nil
+ }
+
+ select {
+ case dfq.recvQueue <- msg:
+
+ // If the recv queue accepted the Container, decrement the recv space.
+ shouldReportRecvSpace := dfq.decrementReportedRecvSpace()
+ // If the reported recv space is nearing its end, force a report, if the
+ // sender worker is idle.
+ if shouldReportRecvSpace {
+ select {
+ case dfq.forceSpaceReport <- struct{}{}:
+ default:
+ }
+ }
+
+ return nil
+ default:
+ // If the recv queue is full, return an error.
+ // The whole point of the flow queue is to guarantee that this never happens.
+ msg.Finish()
+ return ErrQueueOverflow
+ }
+}
+
+// FlowStats returns a k=v formatted string of internal stats.
+func (dfq *DuplexFlowQueue) FlowStats() string {
+ return fmt.Sprintf(
+ "sq=%d rq=%d sends=%d reps=%d",
+ len(dfq.sendQueue),
+ len(dfq.recvQueue),
+ atomic.LoadInt32(dfq.sendSpace),
+ atomic.LoadInt32(dfq.reportedSpace),
+ )
+}
+
+// RecvQueueLen returns the current length of the receive queue.
+func (dfq *DuplexFlowQueue) RecvQueueLen() int {
+ return len(dfq.recvQueue)
+}
+
+// SendQueueLen returns the current length of the send queue.
+func (dfq *DuplexFlowQueue) SendQueueLen() int {
+ return len(dfq.sendQueue)
+}
diff --git a/spn/terminal/defaults.go b/spn/terminal/defaults.go
new file mode 100644
index 00000000..57f17f47
--- /dev/null
+++ b/spn/terminal/defaults.go
@@ -0,0 +1,36 @@
+package terminal
+
+const (
+ // UsePriorityDataMsgs defines whether priority data messages should be used.
+ UsePriorityDataMsgs = true
+)
+
+// DefaultCraneControllerOpts returns the default terminal options for a crane
+// controller terminal.
+func DefaultCraneControllerOpts() *TerminalOpts {
+ return &TerminalOpts{
+ Padding: 0, // Crane already applies padding.
+ FlowControl: FlowControlNone,
+ UsePriorityDataMsgs: UsePriorityDataMsgs,
+ }
+}
+
+// DefaultHomeHubTerminalOpts returns the default terminal options for a crane
+// terminal used for the home hub.
+func DefaultHomeHubTerminalOpts() *TerminalOpts {
+ return &TerminalOpts{
+ Padding: 0, // Crane already applies padding.
+ FlowControl: FlowControlDFQ,
+ UsePriorityDataMsgs: UsePriorityDataMsgs,
+ }
+}
+
+// DefaultExpansionTerminalOpts returns the default terminal options for an
+// expansion terminal.
+func DefaultExpansionTerminalOpts() *TerminalOpts {
+ return &TerminalOpts{
+ Padding: 8,
+ FlowControl: FlowControlDFQ,
+ UsePriorityDataMsgs: UsePriorityDataMsgs,
+ }
+}
diff --git a/spn/terminal/errors.go b/spn/terminal/errors.go
new file mode 100644
index 00000000..619bf181
--- /dev/null
+++ b/spn/terminal/errors.go
@@ -0,0 +1,221 @@
+package terminal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/safing/portbase/formats/varint"
+)
+
+// Error is a terminal error.
+type Error struct {
+ // id holds the internal error ID.
+ id uint8
+ // external signifies if the error was received from the outside.
+ external bool
+ // err holds the wrapped error or the default error message.
+ err error
+}
+
+// ID returns the internal ID of the error.
+func (e *Error) ID() uint8 {
+ return e.id
+}
+
+// Error returns the human readable format of the error.
+func (e *Error) Error() string {
+ if e.external {
+ return "[ext] " + e.err.Error()
+ }
+ return e.err.Error()
+}
+
+// IsExternal returns whether the error occurred externally.
+func (e *Error) IsExternal() bool {
+ if e == nil {
+ return false
+ }
+
+ return e.external
+}
+
+// Is returns whether the given error is of the same type.
+func (e *Error) Is(target error) bool {
+ if e == nil || target == nil {
+ return false
+ }
+
+ t, ok := target.(*Error) //nolint:errorlint // Error implementation, not usage.
+ if !ok {
+ return false
+ }
+ return e.id == t.id
+}
+
+// Unwrap returns the wrapped error.
+func (e *Error) Unwrap() error {
+ if e == nil || e.err == nil {
+ return nil
+ }
+ return e.err
+}
+
+// With adds context and details where the error occurred. The provided
+// message is appended to the error.
+// A new error with the same ID is returned and must be compared with
+// errors.Is().
+func (e *Error) With(format string, a ...interface{}) *Error {
+ // Return nil if error is nil.
+ if e == nil {
+ return nil
+ }
+
+ return &Error{
+ id: e.id,
+ err: fmt.Errorf(e.Error()+": "+format, a...),
+ }
+}
+
+// Wrap adds context higher up in the call chain. The provided message is
+// prepended to the error.
+// A new error with the same ID is returned and must be compared with
+// errors.Is().
+func (e *Error) Wrap(format string, a ...interface{}) *Error {
+ // Return nil if error is nil.
+ if e == nil {
+ return nil
+ }
+
+ return &Error{
+ id: e.id,
+ err: fmt.Errorf(format+": "+e.Error(), a...),
+ }
+}
+
+// AsExternal creates and returns an external version of the error.
+func (e *Error) AsExternal() *Error {
+ // Return nil if error is nil.
+ if e == nil {
+ return nil
+ }
+
+ return &Error{
+ id: e.id,
+ err: e.err,
+ external: true,
+ }
+}
+
+// Pack returns the serialized internal error ID. The additional message is
+// lost and is replaced with the default message upon parsing.
+func (e *Error) Pack() []byte {
+ // Return nil slice if error is nil.
+ if e == nil {
+ return nil
+ }
+
+ return varint.Pack8(e.id)
+}
+
+// ParseExternalError parses an external error.
+func ParseExternalError(id []byte) (*Error, error) {
+ // Return nil for an empty error.
+ if len(id) == 0 {
+ return ErrStopping.AsExternal(), nil
+ }
+
+ parsedID, _, err := varint.Unpack8(id)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unpack error ID: %w", err)
+ }
+
+ return NewExternalError(parsedID), nil
+}
+
+// NewExternalError creates an external error based on the given ID.
+func NewExternalError(id uint8) *Error {
+ err, ok := errorRegistry[id]
+ if ok {
+ return err.AsExternal()
+ }
+
+ return ErrUnknownError.AsExternal()
+}
+
+var errorRegistry = make(map[uint8]*Error)
+
+func registerError(id uint8, err error) *Error {
+ // Check for duplicate.
+ _, ok := errorRegistry[id]
+ if ok {
+ panic(fmt.Sprintf("error with id %d already registered", id))
+ }
+
+ newErr := &Error{
+ id: id,
+ err: err,
+ }
+
+ errorRegistry[id] = newErr
+ return newErr
+}
+
+// func (e *Error) IsSpecial() bool {
+// if e == nil {
+// return false
+// }
+// return e.id > 0 && e.id < 8
+// }
+
+// IsOK returns if the error represents a "OK" or success status.
+func (e *Error) IsOK() bool {
+ return !e.IsError()
+}
+
+// IsError returns if the error represents an erronous condition.
+func (e *Error) IsError() bool {
+ if e == nil || e.err == nil {
+ return false
+ }
+ if e.id == 0 || e.id >= 8 {
+ return true
+ }
+ return false
+}
+
+// Terminal Errors.
+var (
+ // ErrUnknownError is the default error.
+ ErrUnknownError = registerError(0, errors.New("unknown error"))
+
+ // Error IDs 1-7 are reserved for special "OK" values.
+
+ ErrStopping = registerError(2, errors.New("stopping"))
+ ErrExplicitAck = registerError(3, errors.New("explicit ack"))
+ ErrNoActivity = registerError(4, errors.New("no activity"))
+
+ // Errors IDs 8 and up are for regular errors.
+
+ ErrInternalError = registerError(8, errors.New("internal error"))
+ ErrMalformedData = registerError(9, errors.New("malformed data"))
+ ErrUnexpectedMsgType = registerError(10, errors.New("unexpected message type"))
+ ErrUnknownOperationType = registerError(11, errors.New("unknown operation type"))
+ ErrUnknownOperationID = registerError(12, errors.New("unknown operation id"))
+ ErrPermissionDenied = registerError(13, errors.New("permission denied"))
+ ErrIntegrity = registerError(14, errors.New("integrity violated"))
+ ErrInvalidOptions = registerError(15, errors.New("invalid options"))
+ ErrHubNotReady = registerError(16, errors.New("hub not ready"))
+ ErrRateLimited = registerError(24, errors.New("rate limited"))
+ ErrIncorrectUsage = registerError(22, errors.New("incorrect usage"))
+ ErrTimeout = registerError(62, errors.New("timed out"))
+ ErrUnsupportedVersion = registerError(93, errors.New("unsupported version"))
+ ErrHubUnavailable = registerError(101, errors.New("hub unavailable"))
+ ErrAbandonedTerminal = registerError(102, errors.New("terminal is being abandoned"))
+ ErrShipSunk = registerError(108, errors.New("ship sunk"))
+ ErrDestinationUnavailable = registerError(113, errors.New("destination unavailable"))
+ ErrTryAgainLater = registerError(114, errors.New("try again later"))
+ ErrConnectionError = registerError(121, errors.New("connection error"))
+ ErrQueueOverflow = registerError(122, errors.New("queue overflowed"))
+ ErrCanceled = registerError(125, context.Canceled)
+)
diff --git a/spn/terminal/fmt.go b/spn/terminal/fmt.go
new file mode 100644
index 00000000..6bebe3c0
--- /dev/null
+++ b/spn/terminal/fmt.go
@@ -0,0 +1,27 @@
+package terminal
+
+import "fmt"
+
+// CustomTerminalIDFormatting defines an interface for terminal to define their custom ID format.
+type CustomTerminalIDFormatting interface {
+ CustomIDFormat() string
+}
+
+// FmtID formats the terminal ID together with the parent's ID.
+func (t *TerminalBase) FmtID() string {
+ if t.ext != nil {
+ if customFormatting, ok := t.ext.(CustomTerminalIDFormatting); ok {
+ return customFormatting.CustomIDFormat()
+ }
+ }
+
+ return fmtTerminalID(t.parentID, t.id)
+}
+
+func fmtTerminalID(craneID string, terminalID uint32) string {
+ return fmt.Sprintf("%s#%d", craneID, terminalID)
+}
+
+func fmtOperationID(craneID string, terminalID, operationID uint32) string {
+ return fmt.Sprintf("%s#%d>%d", craneID, terminalID, operationID)
+}
diff --git a/spn/terminal/init.go b/spn/terminal/init.go
new file mode 100644
index 00000000..b9960424
--- /dev/null
+++ b/spn/terminal/init.go
@@ -0,0 +1,210 @@
+package terminal
+
+import (
+ "context"
+
+ "github.com/safing/jess"
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/formats/dsd"
+ "github.com/safing/portbase/formats/varint"
+ "github.com/safing/portmaster/spn/cabin"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+/*
+
+Terminal Init Message Format:
+
+- Version [varint]
+- Data Block [bytes; not blocked]
+ - TerminalOpts as DSD
+
+*/
+
+const (
+ minSupportedTerminalVersion = 1
+ maxSupportedTerminalVersion = 1
+)
+
+// TerminalOpts holds configuration for the terminal.
+type TerminalOpts struct { //nolint:golint,maligned // TODO: Rename.
+ Version uint8 `json:"-"`
+ Encrypt bool `json:"e,omitempty"`
+ Padding uint16 `json:"p,omitempty"`
+
+ FlowControl FlowControlType `json:"fc,omitempty"`
+ FlowControlSize uint32 `json:"qs,omitempty"` // Previously was "QueueSize".
+
+ UsePriorityDataMsgs bool `json:"pr,omitempty"`
+}
+
+// ParseTerminalOpts parses terminal options from the container and checks if
+// they are valid.
+func ParseTerminalOpts(c *container.Container) (*TerminalOpts, *Error) {
+ // Parse and check version.
+ version, err := c.GetNextN8()
+ if err != nil {
+ return nil, ErrMalformedData.With("failed to parse version: %w", err)
+ }
+ if version < minSupportedTerminalVersion || version > maxSupportedTerminalVersion {
+ return nil, ErrUnsupportedVersion.With("requested terminal version %d", version)
+ }
+
+ // Parse init message.
+ initMsg := &TerminalOpts{}
+ _, err = dsd.Load(c.CompileData(), initMsg)
+ if err != nil {
+ return nil, ErrMalformedData.With("failed to parse init message: %w", err)
+ }
+ initMsg.Version = version
+
+ // Check if options are valid.
+ tErr := initMsg.Check(false)
+ if tErr != nil {
+ return nil, tErr
+ }
+
+ return initMsg, nil
+}
+
+// Pack serialized the terminal options and checks if they are valid.
+func (opts *TerminalOpts) Pack() (*container.Container, *Error) {
+ // Check if options are valid.
+ tErr := opts.Check(true)
+ if tErr != nil {
+ return nil, tErr
+ }
+
+ // Pack init message.
+ optsData, err := dsd.Dump(opts, dsd.CBOR)
+ if err != nil {
+ return nil, ErrInternalError.With("failed to pack init message: %w", err)
+ }
+
+ // Compile init message.
+ return container.New(
+ varint.Pack8(opts.Version),
+ optsData,
+ ), nil
+}
+
+// Check checks if terminal options are valid.
+func (opts *TerminalOpts) Check(useDefaultsForRequired bool) *Error {
+ // Version is required - use default when permitted.
+ if opts.Version == 0 && useDefaultsForRequired {
+ opts.Version = 1
+ }
+ if opts.Version < minSupportedTerminalVersion || opts.Version > maxSupportedTerminalVersion {
+ return ErrInvalidOptions.With("unsupported terminal version %d", opts.Version)
+ }
+
+ // FlowControl is optional.
+ switch opts.FlowControl {
+ case FlowControlDefault:
+ // Set to default flow control.
+ opts.FlowControl = defaultFlowControl
+ case FlowControlNone, FlowControlDFQ:
+ // Ok.
+ default:
+ return ErrInvalidOptions.With("unknown flow control type: %d", opts.FlowControl)
+ }
+
+ // FlowControlSize is required as it needs to be same on both sides.
+ // Use default when permitted.
+ if opts.FlowControlSize == 0 && useDefaultsForRequired {
+ opts.FlowControlSize = opts.FlowControl.DefaultSize()
+ }
+ if opts.FlowControlSize <= 0 || opts.FlowControlSize > MaxQueueSize {
+ return ErrInvalidOptions.With("invalid flow control size of %d", opts.FlowControlSize)
+ }
+
+ return nil
+}
+
+// NewLocalBaseTerminal creates a new local terminal base for use with inheriting terminals.
+func NewLocalBaseTerminal(
+ ctx context.Context,
+ id uint32,
+ parentID string,
+ remoteHub *hub.Hub,
+ initMsg *TerminalOpts,
+ upstream Upstream,
+) (
+ t *TerminalBase,
+ initData *container.Container,
+ err *Error,
+) {
+ // Pack, check and add defaults to init message.
+ initData, err = initMsg.Pack()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Create baseline.
+ t, err = createTerminalBase(ctx, id, parentID, false, initMsg, upstream)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Setup encryption if enabled.
+ if remoteHub != nil {
+ initMsg.Encrypt = true
+
+ // Select signet (public key) of remote Hub to use.
+ s := remoteHub.SelectSignet()
+ if s == nil {
+ return nil, nil, ErrHubNotReady.With("failed to select signet of remote hub")
+ }
+
+ // Create new session.
+ env := jess.NewUnconfiguredEnvelope()
+ env.SuiteID = jess.SuiteWireV1
+ env.Recipients = []*jess.Signet{s}
+ jession, err := env.WireCorrespondence(nil)
+ if err != nil {
+ return nil, nil, ErrIntegrity.With("failed to initialize encryption: %w", err)
+ }
+ t.jession = jession
+
+ // Encryption is ready for sending.
+ close(t.encryptionReady)
+ }
+
+ return t, initData, nil
+}
+
+// NewRemoteBaseTerminal creates a new remote terminal base for use with inheriting terminals.
+func NewRemoteBaseTerminal(
+ ctx context.Context,
+ id uint32,
+ parentID string,
+ identity *cabin.Identity,
+ initData *container.Container,
+ upstream Upstream,
+) (
+ t *TerminalBase,
+ initMsg *TerminalOpts,
+ err *Error,
+) {
+ // Parse init message.
+ initMsg, err = ParseTerminalOpts(initData)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Create baseline.
+ t, err = createTerminalBase(ctx, id, parentID, true, initMsg, upstream)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Setup encryption if enabled.
+ if initMsg.Encrypt {
+ if identity == nil {
+ return nil, nil, ErrInternalError.With("missing identity for setting up incoming encryption")
+ }
+ t.identity = identity
+ }
+
+ return t, initMsg, nil
+}
diff --git a/spn/terminal/metrics.go b/spn/terminal/metrics.go
new file mode 100644
index 00000000..0da0c326
--- /dev/null
+++ b/spn/terminal/metrics.go
@@ -0,0 +1,117 @@
+package terminal
+
+import (
+ "time"
+
+ "github.com/tevino/abool"
+
+ "github.com/safing/portbase/api"
+ "github.com/safing/portbase/metrics"
+)
+
+var metricsRegistered = abool.New()
+
+func registerMetrics() (err error) {
+ // Only register metrics once.
+ if !metricsRegistered.SetToIf(false, true) {
+ return nil
+ }
+
+ // Get scheduler config and calculat scaling.
+ schedulerConfig := getSchedulerConfig()
+ scaleSlotToSecondsFactor := float64(time.Second / schedulerConfig.SlotDuration)
+
+ // Register metrics from scheduler stats.
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/slotpace/max",
+ nil,
+ metricFromInt(scheduler.GetMaxSlotPace, scaleSlotToSecondsFactor),
+ &metrics.Options{
+ Name: "SPN Scheduling Max Slot Pace (scaled to per second)",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/slotpace/leveled/max",
+ nil,
+ metricFromInt(scheduler.GetMaxLeveledSlotPace, scaleSlotToSecondsFactor),
+ &metrics.Options{
+ Name: "SPN Scheduling Max Leveled Slot Pace (scaled to per second)",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/slotpace/avg",
+ nil,
+ metricFromInt(scheduler.GetAvgSlotPace, scaleSlotToSecondsFactor),
+ &metrics.Options{
+ Name: "SPN Scheduling Avg Slot Pace (scaled to per second)",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/life/avg/seconds",
+ nil,
+ metricFromNanoseconds(scheduler.GetAvgUnitLife),
+ &metrics.Options{
+ Name: "SPN Scheduling Avg Unit Life",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/workslot/avg/seconds",
+ nil,
+ metricFromNanoseconds(scheduler.GetAvgWorkSlotDuration),
+ &metrics.Options{
+ Name: "SPN Scheduling Avg Work Slot Duration",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ _, err = metrics.NewGauge(
+ "spn/scheduling/unit/catchupslot/avg/seconds",
+ nil,
+ metricFromNanoseconds(scheduler.GetAvgCatchUpSlotDuration),
+ &metrics.Options{
+ Name: "SPN Scheduling Avg Catch-Up Slot Duration",
+ Permission: api.PermitUser,
+ },
+ )
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func metricFromInt(fn func() int64, scaleFactor float64) func() float64 {
+ return func() float64 {
+ return float64(fn()) * scaleFactor
+ }
+}
+
+func metricFromNanoseconds(fn func() int64) func() float64 {
+ return func() float64 {
+ return float64(fn()) / float64(time.Second)
+ }
+}
diff --git a/spn/terminal/module.go b/spn/terminal/module.go
new file mode 100644
index 00000000..178bc08c
--- /dev/null
+++ b/spn/terminal/module.go
@@ -0,0 +1,80 @@
+package terminal
+
+import (
+ "flag"
+ "time"
+
+ "github.com/safing/portbase/modules"
+ "github.com/safing/portbase/rng"
+ "github.com/safing/portmaster/spn/conf"
+ "github.com/safing/portmaster/spn/unit"
+)
+
+var (
+ module *modules.Module
+ rngFeeder *rng.Feeder = rng.NewFeeder()
+
+ scheduler *unit.Scheduler
+
+ debugUnitScheduling bool
+)
+
+func init() {
+ flag.BoolVar(&debugUnitScheduling, "debug-unit-scheduling", false, "enable debug logs of the SPN unit scheduler")
+
+ module = modules.Register("terminal", nil, start, nil, "base")
+}
+
+func start() error {
+ rngFeeder = rng.NewFeeder()
+
+ scheduler = unit.NewScheduler(getSchedulerConfig())
+ if debugUnitScheduling {
+ // Debug unit leaks.
+ scheduler.StartDebugLog()
+ }
+ module.StartServiceWorker("msg unit scheduler", 0, scheduler.SlotScheduler)
+
+ lockOpRegistry()
+
+ return registerMetrics()
+}
+
+var waitForever chan time.Time
+
+// TimedOut returns a channel that triggers when the timeout is reached.
+func TimedOut(timeout time.Duration) <-chan time.Time {
+ if timeout == 0 {
+ return waitForever
+ }
+ return time.After(timeout)
+}
+
+// StopScheduler stops the unit scheduler.
+func StopScheduler() {
+ if scheduler != nil {
+ scheduler.Stop()
+ }
+}
+
+func getSchedulerConfig() *unit.SchedulerConfig {
+ // Client Scheduler Config.
+ if conf.Client() {
+ return &unit.SchedulerConfig{
+ SlotDuration: 10 * time.Millisecond, // 100 slots per second
+ MinSlotPace: 10, // 1000pps - Small starting pace for low end devices.
+ WorkSlotPercentage: 0.9, // 90%
+ SlotChangeRatePerStreak: 0.1, // 10% - Increase/Decrease quickly.
+ StatCycleDuration: 1 * time.Minute, // Match metrics report cycle.
+ }
+ }
+
+ // Server Scheduler Config.
+ return &unit.SchedulerConfig{
+ SlotDuration: 10 * time.Millisecond, // 100 slots per second
+ MinSlotPace: 100, // 10000pps - Every server should be able to handle this.
+ WorkSlotPercentage: 0.7, // 70%
+ SlotChangeRatePerStreak: 0.05, // 5%
+ StatCycleDuration: 1 * time.Minute, // Match metrics report cycle.
+ }
+}
diff --git a/spn/terminal/module_test.go b/spn/terminal/module_test.go
new file mode 100644
index 00000000..1f07003d
--- /dev/null
+++ b/spn/terminal/module_test.go
@@ -0,0 +1,13 @@
+package terminal
+
+import (
+ "testing"
+
+ "github.com/safing/portmaster/service/core/pmtesting"
+ "github.com/safing/portmaster/spn/conf"
+)
+
+func TestMain(m *testing.M) {
+ conf.EnablePublicHub(true)
+ pmtesting.TestMain(m, module)
+}
diff --git a/spn/terminal/msg.go b/spn/terminal/msg.go
new file mode 100644
index 00000000..8ca00489
--- /dev/null
+++ b/spn/terminal/msg.go
@@ -0,0 +1,106 @@
+package terminal
+
+import (
+ "fmt"
+ "runtime"
+
+ "github.com/safing/portbase/container"
+ "github.com/safing/portmaster/spn/unit"
+)
+
+// Msg is a message within the SPN network stack.
+// It includes metadata and unit scheduling.
+type Msg struct {
+ FlowID uint32
+ Type MsgType
+ Data *container.Container
+
+ // Unit scheduling.
+ // Note: With just 100B per packet, a uint64 (the Unit ID) is enough for
+ // over 1800 Exabyte. No need for overflow support.
+ Unit *unit.Unit
+}
+
+// NewMsg returns a new msg.
+// The FlowID is unset.
+// The Type is Data.
+func NewMsg(data []byte) *Msg {
+ msg := &Msg{
+ Type: MsgTypeData,
+ Data: container.New(data),
+ Unit: scheduler.NewUnit(),
+ }
+
+ // Debug unit leaks.
+ msg.debugWithCaller(2)
+
+ return msg
+}
+
+// NewEmptyMsg returns a new empty msg with an initialized Unit.
+// The FlowID is unset.
+// The Type is Data.
+// The Data is unset.
+func NewEmptyMsg() *Msg {
+ msg := &Msg{
+ Type: MsgTypeData,
+ Unit: scheduler.NewUnit(),
+ }
+
+ // Debug unit leaks.
+ msg.debugWithCaller(2)
+
+ return msg
+}
+
+// Pack prepends the message header (Length and ID+Type) to the data.
+func (msg *Msg) Pack() {
+ MakeMsg(msg.Data, msg.FlowID, msg.Type)
+}
+
+// Consume adds another Message to itself.
+// The given Msg is packed before adding it to the data.
+// The data is moved - not copied!
+// High priority mark is inherited.
+func (msg *Msg) Consume(other *Msg) {
+ // Pack message to be added.
+ other.Pack()
+
+ // Move data.
+ msg.Data.AppendContainer(other.Data)
+
+ // Inherit high priority.
+ if other.Unit.IsHighPriority() {
+ msg.Unit.MakeHighPriority()
+ }
+
+ // Finish other unit.
+ other.Finish()
+}
+
+// Finish signals the unit scheduler that this unit has finished processing.
+// Will no-op if called on a nil Msg.
+func (msg *Msg) Finish() {
+ // Proxying is necessary, as a nil msg still panics.
+ if msg == nil {
+ return
+ }
+ msg.Unit.Finish()
+}
+
+// Debug registers the unit for debug output with the given source.
+// Additional calls on the same unit update the unit source.
+// StartDebugLog() must be called before calling DebugUnit().
+func (msg *Msg) Debug() {
+ msg.debugWithCaller(2)
+}
+
+func (msg *Msg) debugWithCaller(skip int) { //nolint:unparam
+ if !debugUnitScheduling || msg == nil {
+ return
+ }
+ _, file, line, ok := runtime.Caller(skip)
+ if ok {
+ scheduler.DebugUnit(msg.Unit, fmt.Sprintf("%s:%d", file, line))
+ }
+}
diff --git a/spn/terminal/msgtypes.go b/spn/terminal/msgtypes.go
new file mode 100644
index 00000000..df712618
--- /dev/null
+++ b/spn/terminal/msgtypes.go
@@ -0,0 +1,66 @@
+package terminal
+
+import (
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/formats/varint"
+)
+
+/*
+Terminal and Operation Message Format:
+
+- Length [varint]
+ - If Length is 0, the remainder of given data is padding.
+- IDType [varint]
+ - Type [uses least two significant bits]
+ - One of Init, Data, Stop
+ - ID [uses all other bits]
+ - The ID is currently not adapted in order to make reading raw message
+ easier. This means that IDs are currently always a multiple of 4.
+- Data [bytes; format depends on msg type]
+ - MsgTypeInit:
+ - Data [bytes]
+ - MsgTypeData:
+ - AddAvailableSpace [varint, if Flow Queue is used]
+ - (Encrypted) Data [bytes]
+ - MsgTypeStop:
+ - Error Code [varint]
+*/
+
+// MsgType is the message type for both terminals and operations.
+type MsgType uint8
+
+const (
+ // MsgTypeInit is used to establish a new terminal or run a new operation.
+ MsgTypeInit MsgType = 1
+
+ // MsgTypeData is used to send data to a terminal or operation.
+ MsgTypeData MsgType = 2
+
+ // MsgTypePriorityData is used to send prioritized data to a terminal or operation.
+ MsgTypePriorityData MsgType = 0
+
+ // MsgTypeStop is used to abandon a terminal or end an operation, with an optional error.
+ MsgTypeStop MsgType = 3
+)
+
+// AddIDType prepends the ID and Type header to the message.
+func AddIDType(c *container.Container, id uint32, msgType MsgType) {
+ c.Prepend(varint.Pack32(id | uint32(msgType)))
+}
+
+// MakeMsg prepends the message header (Length and ID+Type) to the data.
+func MakeMsg(c *container.Container, id uint32, msgType MsgType) {
+ AddIDType(c, id, msgType)
+ c.PrependLength()
+}
+
+// ParseIDType parses the combined message ID and type.
+func ParseIDType(c *container.Container) (id uint32, msgType MsgType, err error) {
+ idType, err := c.GetNextN32()
+ if err != nil {
+ return 0, 0, err
+ }
+
+ msgType = MsgType(idType % 4)
+ return idType - uint32(msgType), msgType, nil
+}
diff --git a/spn/terminal/operation.go b/spn/terminal/operation.go
new file mode 100644
index 00000000..100936ec
--- /dev/null
+++ b/spn/terminal/operation.go
@@ -0,0 +1,332 @@
+package terminal
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tevino/abool"
+
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/log"
+ "github.com/safing/portbase/utils"
+)
+
+// Operation is an interface for all operations.
+type Operation interface {
+ // InitOperationBase initialize the operation with the ID and attached terminal.
+ // Should not be overridden by implementations.
+ InitOperationBase(t Terminal, opID uint32)
+
+ // ID returns the ID of the operation.
+ // Should not be overridden by implementations.
+ ID() uint32
+
+ // Type returns the operation's type ID.
+ // Should be overridden by implementations to return correct type ID.
+ Type() string
+
+ // Deliver delivers a message to the operation.
+ // Meant to be overridden by implementations.
+ Deliver(msg *Msg) *Error
+
+ // NewMsg creates a new message from this operation.
+ // Should not be overridden by implementations.
+ NewMsg(data []byte) *Msg
+
+ // Send sends a message to the other side.
+ // Should not be overridden by implementations.
+ Send(msg *Msg, timeout time.Duration) *Error
+
+ // Flush sends all messages waiting in the terminal.
+ // Should not be overridden by implementations.
+ Flush(timeout time.Duration)
+
+ // Stopped returns whether the operation has stopped.
+ // Should not be overridden by implementations.
+ Stopped() bool
+
+ // markStopped marks the operation as stopped.
+ // It returns whether the stop flag was set.
+ markStopped() bool
+
+ // Stop stops the operation by unregistering it from the terminal and calling HandleStop().
+ // Should not be overridden by implementations.
+ Stop(self Operation, err *Error)
+
+ // HandleStop gives the operation the ability to cleanly shut down.
+ // The returned error is the error to send to the other side.
+ // Should never be called directly. Call Stop() instead.
+ // Meant to be overridden by implementations.
+ HandleStop(err *Error) (errorToSend *Error)
+
+ // Terminal returns the terminal the operation is linked to.
+ // Should not be overridden by implementations.
+ Terminal() Terminal
+}
+
+// OperationFactory defines an operation factory.
+type OperationFactory struct {
+ // Type is the type id of an operation.
+ Type string
+ // Requires defines the required permissions to run an operation.
+ Requires Permission
+ // Start is the function that starts a new operation.
+ Start OperationStarter
+}
+
+// OperationStarter is used to initialize operations remotely.
+type OperationStarter func(attachedTerminal Terminal, opID uint32, initData *container.Container) (Operation, *Error)
+
+var (
+ opRegistry = make(map[string]*OperationFactory)
+ opRegistryLock sync.Mutex
+ opRegistryLocked = abool.New()
+)
+
+// RegisterOpType registers a new operation type and may only be called during
+// Go's init and a module's prep phase.
+func RegisterOpType(factory OperationFactory) {
+ // Check if we can still register an operation type.
+ if opRegistryLocked.IsSet() {
+ log.Errorf("spn/terminal: failed to register operation %s: operation registry is already locked", factory.Type)
+ return
+ }
+
+ opRegistryLock.Lock()
+ defer opRegistryLock.Unlock()
+
+ // Check if the operation type was already registered.
+ if _, ok := opRegistry[factory.Type]; ok {
+ log.Errorf("spn/terminal: failed to register operation type %s: type already registered", factory.Type)
+ return
+ }
+
+ // Save to registry.
+ opRegistry[factory.Type] = &factory
+}
+
+func lockOpRegistry() {
+ opRegistryLocked.Set()
+}
+
+func (t *TerminalBase) handleOperationStart(opID uint32, initData *container.Container) {
+ // Check if the terminal is being abandoned.
+ if t.Abandoning.IsSet() {
+ t.StopOperation(newUnknownOp(opID, ""), ErrAbandonedTerminal)
+ return
+ }
+
+ // Extract the requested operation name.
+ opType, err := initData.GetNextBlock()
+ if err != nil {
+ t.StopOperation(newUnknownOp(opID, ""), ErrMalformedData.With("failed to get init data: %w", err))
+ return
+ }
+
+ // Get the operation factory from the registry.
+ factory, ok := opRegistry[string(opType)]
+ if !ok {
+ t.StopOperation(newUnknownOp(opID, ""), ErrUnknownOperationType.With(utils.SafeFirst16Bytes(opType)))
+ return
+ }
+
+ // Check if the Terminal has the required permission to run the operation.
+ if !t.HasPermission(factory.Requires) {
+ t.StopOperation(newUnknownOp(opID, factory.Type), ErrPermissionDenied)
+ return
+ }
+
+ // Get terminal to attach to.
+ attachToTerminal := t.ext
+ if attachToTerminal == nil {
+ attachToTerminal = t
+ }
+
+ // Run the operation.
+ op, opErr := factory.Start(attachToTerminal, opID, initData)
+ switch {
+ case opErr != nil:
+ // Something went wrong.
+ t.StopOperation(newUnknownOp(opID, factory.Type), opErr)
+ case op == nil:
+ // The Operation was successful and is done already.
+ log.Debugf("spn/terminal: operation %s %s executed", factory.Type, fmtOperationID(t.parentID, t.id, opID))
+ t.StopOperation(newUnknownOp(opID, factory.Type), nil)
+ default:
+ // The operation started successfully and requires persistence.
+ t.SetActiveOp(opID, op)
+ log.Debugf("spn/terminal: operation %s %s started", factory.Type, fmtOperationID(t.parentID, t.id, opID))
+ }
+}
+
+// StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data.
+func (t *TerminalBase) StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error {
+ // Get terminal to attach to.
+ attachToTerminal := t.ext
+ if attachToTerminal == nil {
+ attachToTerminal = t
+ }
+
+ // Get the next operation ID and set it on the operation with the terminal.
+ op.InitOperationBase(attachToTerminal, atomic.AddUint32(t.nextOpID, 8))
+
+ // Always add operation to the active operations, as we need to receive a
+ // reply in any case.
+ t.SetActiveOp(op.ID(), op)
+
+ log.Debugf("spn/terminal: operation %s %s started", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()))
+
+ // Add or create the operation type block.
+ if initData == nil {
+ initData = container.New()
+ initData.AppendAsBlock([]byte(op.Type()))
+ } else {
+ initData.PrependAsBlock([]byte(op.Type()))
+ }
+
+ // Create init msg.
+ msg := NewEmptyMsg()
+ msg.FlowID = op.ID()
+ msg.Type = MsgTypeInit
+ msg.Data = initData
+ msg.Unit.MakeHighPriority()
+
+ // Send init msg.
+ err := op.Send(msg, timeout)
+ if err != nil {
+ msg.Finish()
+ }
+ return err
+}
+
+// Send sends data via this terminal.
+// If a timeout is set, sending will fail after the given timeout passed.
+func (t *TerminalBase) Send(msg *Msg, timeout time.Duration) *Error {
+ // Wait for processing slot.
+ msg.Unit.WaitForSlot()
+
+ // Check if the send queue has available space.
+ select {
+ case t.sendQueue <- msg:
+ return nil
+ default:
+ }
+
+ // Submit message to buffer, if space is available.
+ select {
+ case t.sendQueue <- msg:
+ return nil
+ case <-TimedOut(timeout):
+ msg.Finish()
+ return ErrTimeout.With("sending via terminal")
+ case <-t.Ctx().Done():
+ msg.Finish()
+ return ErrStopping
+ }
+}
+
+// StopOperation sends the end signal with an optional error and then deletes
+// the operation from the Terminal state and calls HandleStop() on the Operation.
+func (t *TerminalBase) StopOperation(op Operation, err *Error) {
+ // Check if the operation has already stopped.
+ if !op.markStopped() {
+ return
+ }
+
+ // Log reason the Operation is ending. Override stopping error with nil.
+ switch {
+ case err == nil:
+ log.Debugf("spn/terminal: operation %s %s stopped", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()))
+ case err.IsOK(), err.Is(ErrTryAgainLater), err.Is(ErrRateLimited):
+ log.Debugf("spn/terminal: operation %s %s stopped: %s", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()), err)
+ default:
+ log.Warningf("spn/terminal: operation %s %s failed: %s", op.Type(), fmtOperationID(t.parentID, t.id, op.ID()), err)
+ }
+
+ module.StartWorker("stop operation", func(_ context.Context) error {
+ // Call operation stop handle function for proper shutdown cleaning up.
+ err = op.HandleStop(err)
+
+ // Send error to the connected Operation, if the error is internal.
+ if !err.IsExternal() {
+ if err == nil {
+ err = ErrStopping
+ }
+
+ msg := NewMsg(err.Pack())
+ msg.FlowID = op.ID()
+ msg.Type = MsgTypeStop
+
+ tErr := t.Send(msg, 10*time.Second)
+ if tErr != nil {
+ msg.Finish()
+ log.Warningf("spn/terminal: failed to send stop msg: %s", tErr)
+ }
+ }
+
+ // Remove operation from terminal.
+ t.DeleteActiveOp(op.ID())
+
+ return nil
+ })
+}
+
+// GetActiveOp returns the active operation with the given ID from the
+// Terminal state.
+func (t *TerminalBase) GetActiveOp(opID uint32) (op Operation, ok bool) {
+ t.lock.RLock()
+ defer t.lock.RUnlock()
+
+ op, ok = t.operations[opID]
+ return
+}
+
+// SetActiveOp saves an active operation to the Terminal state.
+func (t *TerminalBase) SetActiveOp(opID uint32, op Operation) {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ t.operations[opID] = op
+}
+
+// DeleteActiveOp deletes an active operation from the Terminal state.
+func (t *TerminalBase) DeleteActiveOp(opID uint32) {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ delete(t.operations, opID)
+}
+
+// GetActiveOpCount returns the amount of active operations.
+func (t *TerminalBase) GetActiveOpCount() int {
+ t.lock.RLock()
+ defer t.lock.RUnlock()
+
+ return len(t.operations)
+}
+
+func newUnknownOp(id uint32, typeID string) *unknownOp {
+ op := &unknownOp{
+ typeID: typeID,
+ }
+ op.id = id
+ return op
+}
+
+type unknownOp struct {
+ OperationBase
+ typeID string
+}
+
+func (op *unknownOp) Type() string {
+ if op.typeID != "" {
+ return op.typeID
+ }
+ return "unknown"
+}
+
+func (op *unknownOp) Deliver(msg *Msg) *Error {
+ return ErrIncorrectUsage.With("unknown op shim cannot receive")
+}
diff --git a/spn/terminal/operation_base.go b/spn/terminal/operation_base.go
new file mode 100644
index 00000000..4b588c4f
--- /dev/null
+++ b/spn/terminal/operation_base.go
@@ -0,0 +1,185 @@
+package terminal
+
+import (
+ "time"
+
+ "github.com/tevino/abool"
+)
+
+// OperationBase provides the basic operation functionality.
+type OperationBase struct {
+ terminal Terminal
+ id uint32
+ stopped abool.AtomicBool
+}
+
+// InitOperationBase initialize the operation with the ID and attached terminal.
+// Should not be overridden by implementations.
+func (op *OperationBase) InitOperationBase(t Terminal, opID uint32) {
+ op.id = opID
+ op.terminal = t
+}
+
+// ID returns the ID of the operation.
+// Should not be overridden by implementations.
+func (op *OperationBase) ID() uint32 {
+ return op.id
+}
+
+// Type returns the operation's type ID.
+// Should be overridden by implementations to return correct type ID.
+func (op *OperationBase) Type() string {
+ return "unknown"
+}
+
+// Deliver delivers a message to the operation.
+// Meant to be overridden by implementations.
+func (op *OperationBase) Deliver(_ *Msg) *Error {
+ return ErrIncorrectUsage.With("Deliver not implemented for this operation")
+}
+
+// NewMsg creates a new message from this operation.
+// Should not be overridden by implementations.
+func (op *OperationBase) NewMsg(data []byte) *Msg {
+ msg := NewMsg(data)
+ msg.FlowID = op.id
+ msg.Type = MsgTypeData
+
+ // Debug unit leaks.
+ msg.debugWithCaller(2)
+
+ return msg
+}
+
+// NewEmptyMsg creates a new empty message from this operation.
+// Should not be overridden by implementations.
+func (op *OperationBase) NewEmptyMsg() *Msg {
+ msg := NewEmptyMsg()
+ msg.FlowID = op.id
+ msg.Type = MsgTypeData
+
+ // Debug unit leaks.
+ msg.debugWithCaller(2)
+
+ return msg
+}
+
+// Send sends a message to the other side.
+// Should not be overridden by implementations.
+func (op *OperationBase) Send(msg *Msg, timeout time.Duration) *Error {
+ // Add and update metadata.
+ msg.FlowID = op.id
+ if msg.Type == MsgTypeData && msg.Unit.IsHighPriority() && UsePriorityDataMsgs {
+ msg.Type = MsgTypePriorityData
+ }
+
+ // Wait for processing slot.
+ msg.Unit.WaitForSlot()
+
+ // Send message.
+ tErr := op.terminal.Send(msg, timeout)
+ if tErr != nil {
+ // Finish message unit on failure.
+ msg.Finish()
+ }
+ return tErr
+}
+
+// Flush sends all messages waiting in the terminal.
+// Meant to be overridden by implementations.
+func (op *OperationBase) Flush(timeout time.Duration) {
+ op.terminal.Flush(timeout)
+}
+
+// Stopped returns whether the operation has stopped.
+// Should not be overridden by implementations.
+func (op *OperationBase) Stopped() bool {
+ return op.stopped.IsSet()
+}
+
+// markStopped marks the operation as stopped.
+// It returns whether the stop flag was set.
+func (op *OperationBase) markStopped() bool {
+ return op.stopped.SetToIf(false, true)
+}
+
+// Stop stops the operation by unregistering it from the terminal and calling HandleStop().
+// Should not be overridden by implementations.
+func (op *OperationBase) Stop(self Operation, err *Error) {
+ // Stop operation from terminal.
+ op.terminal.StopOperation(self, err)
+}
+
+// HandleStop gives the operation the ability to cleanly shut down.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Stop() instead.
+// Meant to be overridden by implementations.
+func (op *OperationBase) HandleStop(err *Error) (errorToSend *Error) {
+ return err
+}
+
+// Terminal returns the terminal the operation is linked to.
+// Should not be overridden by implementations.
+func (op *OperationBase) Terminal() Terminal {
+ return op.terminal
+}
+
+// OneOffOperationBase is an operation base for operations that just have one
+// message and a error return.
+type OneOffOperationBase struct {
+ OperationBase
+
+ Result chan *Error
+}
+
+// Init initializes the single operation base.
+func (op *OneOffOperationBase) Init() {
+ op.Result = make(chan *Error, 1)
+}
+
+// HandleStop gives the operation the ability to cleanly shut down.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Stop() instead.
+func (op *OneOffOperationBase) HandleStop(err *Error) (errorToSend *Error) {
+ select {
+ case op.Result <- err:
+ default:
+ }
+ return err
+}
+
+// MessageStreamOperationBase is an operation base for receiving a message stream.
+// Every received message must be finished by the implementing operation.
+type MessageStreamOperationBase struct {
+ OperationBase
+
+ Delivered chan *Msg
+ Ended chan *Error
+}
+
+// Init initializes the operation base.
+func (op *MessageStreamOperationBase) Init(deliverQueueSize int) {
+ op.Delivered = make(chan *Msg, deliverQueueSize)
+ op.Ended = make(chan *Error, 1)
+}
+
+// Deliver delivers data to the operation.
+func (op *MessageStreamOperationBase) Deliver(msg *Msg) *Error {
+ select {
+ case op.Delivered <- msg:
+ return nil
+ default:
+ return ErrIncorrectUsage.With("request was not waiting for data")
+ }
+}
+
+// HandleStop gives the operation the ability to cleanly shut down.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Stop() instead.
+func (op *MessageStreamOperationBase) HandleStop(err *Error) (errorToSend *Error) {
+ select {
+ case op.Ended <- err:
+ default:
+ }
+ return err
+}
diff --git a/spn/terminal/operation_counter.go b/spn/terminal/operation_counter.go
new file mode 100644
index 00000000..59d175e0
--- /dev/null
+++ b/spn/terminal/operation_counter.go
@@ -0,0 +1,255 @@
+package terminal
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/formats/dsd"
+ "github.com/safing/portbase/formats/varint"
+ "github.com/safing/portbase/log"
+)
+
+// CounterOpType is the type ID for the Counter Operation.
+const CounterOpType string = "debug/count"
+
+// CounterOp sends increasing numbers on both sides.
+type CounterOp struct { //nolint:maligned
+ OperationBase
+
+ wg sync.WaitGroup
+ server bool
+ opts *CounterOpts
+
+ counterLock sync.Mutex
+ ClientCounter uint64
+ ServerCounter uint64
+ Error error
+}
+
+// CounterOpts holds the options for CounterOp.
+type CounterOpts struct {
+ ClientCountTo uint64
+ ServerCountTo uint64
+ Wait time.Duration
+ Flush bool
+
+ suppressWorker bool
+}
+
+func init() {
+ RegisterOpType(OperationFactory{
+ Type: CounterOpType,
+ Start: startCounterOp,
+ })
+}
+
+// NewCounterOp returns a new CounterOp.
+func NewCounterOp(t Terminal, opts CounterOpts) (*CounterOp, *Error) {
+ // Create operation.
+ op := &CounterOp{
+ opts: &opts,
+ }
+ op.wg.Add(1)
+
+ // Create argument container.
+ data, err := dsd.Dump(op.opts, dsd.JSON)
+ if err != nil {
+ return nil, ErrInternalError.With("failed to pack options: %w", err)
+ }
+
+ // Initialize operation.
+ tErr := t.StartOperation(op, container.New(data), 3*time.Second)
+ if tErr != nil {
+ return nil, tErr
+ }
+
+ // Start worker if needed.
+ if op.getRemoteCounterTarget() > 0 && !op.opts.suppressWorker {
+ module.StartWorker("counter sender", op.CounterWorker)
+ }
+ return op, nil
+}
+
+func startCounterOp(t Terminal, opID uint32, data *container.Container) (Operation, *Error) {
+ // Create operation.
+ op := &CounterOp{
+ server: true,
+ }
+ op.InitOperationBase(t, opID)
+ op.wg.Add(1)
+
+ // Parse arguments.
+ opts := &CounterOpts{}
+ _, err := dsd.Load(data.CompileData(), opts)
+ if err != nil {
+ return nil, ErrInternalError.With("failed to unpack options: %w", err)
+ }
+ op.opts = opts
+
+ // Start worker if needed.
+ if op.getRemoteCounterTarget() > 0 {
+ module.StartWorker("counter sender", op.CounterWorker)
+ }
+
+ return op, nil
+}
+
+// Type returns the operation's type ID.
+func (op *CounterOp) Type() string {
+ return CounterOpType
+}
+
+func (op *CounterOp) getCounter(sending, increase bool) uint64 {
+ op.counterLock.Lock()
+ defer op.counterLock.Unlock()
+
+ // Use server counter, when op is server or for sending, but not when both.
+ if op.server != sending {
+ if increase {
+ op.ServerCounter++
+ }
+ return op.ServerCounter
+ }
+
+ if increase {
+ op.ClientCounter++
+ }
+ return op.ClientCounter
+}
+
+func (op *CounterOp) getRemoteCounterTarget() uint64 {
+ if op.server {
+ return op.opts.ClientCountTo
+ }
+ return op.opts.ServerCountTo
+}
+
+func (op *CounterOp) isDone() bool {
+ op.counterLock.Lock()
+ defer op.counterLock.Unlock()
+
+ return op.ClientCounter >= op.opts.ClientCountTo &&
+ op.ServerCounter >= op.opts.ServerCountTo
+}
+
+// Deliver delivers data to the operation.
+func (op *CounterOp) Deliver(msg *Msg) *Error {
+ defer msg.Finish()
+
+ nextStep, err := msg.Data.GetNextN64()
+ if err != nil {
+ op.Stop(op, ErrMalformedData.With("failed to parse next number: %w", err))
+ return nil
+ }
+
+ // Count and compare.
+ counter := op.getCounter(false, true)
+
+ // Debugging:
+ // if counter < 100 ||
+ // counter < 1000 && counter%100 == 0 ||
+ // counter < 10000 && counter%1000 == 0 ||
+ // counter < 100000 && counter%10000 == 0 ||
+ // counter < 1000000 && counter%100000 == 0 {
+ // log.Errorf("spn/terminal: counter %s>%d recvd, now at %d", op.t.FmtID(), op.id, counter)
+ // }
+
+ if counter != nextStep {
+ log.Warningf(
+ "terminal: integrity of counter op violated: received %d, expected %d",
+ nextStep,
+ counter,
+ )
+ op.Stop(op, ErrIntegrity.With("counters mismatched"))
+ return nil
+ }
+
+ // Check if we are done.
+ if op.isDone() {
+ op.Stop(op, nil)
+ }
+
+ return nil
+}
+
+// HandleStop handles stopping the operation.
+func (op *CounterOp) HandleStop(err *Error) (errorToSend *Error) {
+ // Check if counting finished.
+ if !op.isDone() {
+ err := fmt.Errorf(
+ "counter op %d: did not finish counting (%d<-%d %d->%d)",
+ op.id,
+ op.opts.ClientCountTo, op.ClientCounter,
+ op.ServerCounter, op.opts.ServerCountTo,
+ )
+ op.Error = err
+ }
+
+ op.wg.Done()
+ return err
+}
+
+// SendCounter sends the next counter.
+func (op *CounterOp) SendCounter() *Error {
+ if op.Stopped() {
+ return ErrStopping
+ }
+
+ // Increase sending counter.
+ counter := op.getCounter(true, true)
+
+ // Debugging:
+ // if counter < 100 ||
+ // counter < 1000 && counter%100 == 0 ||
+ // counter < 10000 && counter%1000 == 0 ||
+ // counter < 100000 && counter%10000 == 0 ||
+ // counter < 1000000 && counter%100000 == 0 {
+ // defer log.Errorf("spn/terminal: counter %s>%d sent, now at %d", op.t.FmtID(), op.id, counter)
+ // }
+
+ return op.Send(op.NewMsg(varint.Pack64(counter)), 3*time.Second)
+}
+
+// Wait waits for the Counter Op to finish.
+func (op *CounterOp) Wait() {
+ op.wg.Wait()
+}
+
+// CounterWorker is a worker that sends counters.
+func (op *CounterOp) CounterWorker(ctx context.Context) error {
+ for {
+ // Send counter msg.
+ err := op.SendCounter()
+ switch err {
+ case nil:
+ // All good, continue.
+ case ErrStopping:
+ // Done!
+ return nil
+ default:
+ // Something went wrong.
+ err := fmt.Errorf("counter op %d: failed to send counter: %w", op.id, err)
+ op.Error = err
+ op.Stop(op, ErrInternalError.With(err.Error()))
+ return nil
+ }
+
+ // Maybe flush message.
+ if op.opts.Flush {
+ op.terminal.Flush(1 * time.Second)
+ }
+
+ // Check if we are done with sending.
+ if op.getCounter(true, false) >= op.getRemoteCounterTarget() {
+ return nil
+ }
+
+ // Maybe wait a little.
+ if op.opts.Wait > 0 {
+ time.Sleep(op.opts.Wait)
+ }
+ }
+}
diff --git a/spn/terminal/permission.go b/spn/terminal/permission.go
new file mode 100644
index 00000000..ee39e28a
--- /dev/null
+++ b/spn/terminal/permission.go
@@ -0,0 +1,50 @@
+package terminal
+
+// Permission is a bit-map of granted permissions.
+type Permission uint16
+
+// Permissions.
+const (
+ NoPermission Permission = 0x0
+ MayExpand Permission = 0x1
+ MayConnect Permission = 0x2
+ IsHubOwner Permission = 0x100
+ IsHubAdvisor Permission = 0x200
+ IsCraneController Permission = 0x8000
+)
+
+// AuthorizingTerminal is an interface for terminals that support authorization.
+type AuthorizingTerminal interface {
+ GrantPermission(grant Permission)
+ HasPermission(required Permission) bool
+}
+
+// GrantPermission grants the specified permissions to the Terminal.
+func (t *TerminalBase) GrantPermission(grant Permission) {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ t.permission |= grant
+}
+
+// HasPermission returns if the Terminal has the specified permission.
+func (t *TerminalBase) HasPermission(required Permission) bool {
+ t.lock.RLock()
+ defer t.lock.RUnlock()
+
+ return t.permission.Has(required)
+}
+
+// Has returns if the permission includes the specified permission.
+func (p Permission) Has(required Permission) bool {
+ return p&required == required
+}
+
+// AddPermissions combines multiple permissions.
+func AddPermissions(perms ...Permission) Permission {
+ var all Permission
+ for _, p := range perms {
+ all |= p
+ }
+ return all
+}
diff --git a/spn/terminal/rate_limit.go b/spn/terminal/rate_limit.go
new file mode 100644
index 00000000..162afca0
--- /dev/null
+++ b/spn/terminal/rate_limit.go
@@ -0,0 +1,39 @@
+package terminal
+
+import "time"
+
+// RateLimiter is a data flow rate limiter.
+type RateLimiter struct {
+ maxBytesPerSlot uint64
+ slotBytes uint64
+ slotStarted time.Time
+}
+
+// NewRateLimiter returns a new rate limiter.
+// The given MBit/s are transformed to bytes, so giving a multiple of 8 is
+// advised for accurate results.
+func NewRateLimiter(mbits uint64) *RateLimiter {
+ return &RateLimiter{
+ maxBytesPerSlot: (mbits / 8) * 1_000_000,
+ slotStarted: time.Now(),
+ }
+}
+
+// Limit is given the current transferred bytes and blocks until they may be sent.
+func (rl *RateLimiter) Limit(xferBytes uint64) {
+ // Check if we need to limit transfer if we go over to max bytes per slot.
+ if rl.slotBytes > rl.maxBytesPerSlot {
+ // Wait if we are still within the slot.
+ sinceSlotStart := time.Since(rl.slotStarted)
+ if sinceSlotStart < time.Second {
+ time.Sleep(time.Second - sinceSlotStart)
+ }
+
+ // Reset state for next slot.
+ rl.slotBytes = 0
+ rl.slotStarted = time.Now()
+ }
+
+ // Add new bytes after checking, as first step over the limit is fully using the limit.
+ rl.slotBytes += xferBytes
+}
diff --git a/spn/terminal/session.go b/spn/terminal/session.go
new file mode 100644
index 00000000..fa2d1695
--- /dev/null
+++ b/spn/terminal/session.go
@@ -0,0 +1,166 @@
+package terminal
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/safing/portbase/log"
+)
+
+const (
+ rateLimitMinOps = 250
+ rateLimitMaxOpsPerSecond = 5
+
+ rateLimitMinSuspicion = 25
+ rateLimitMinPermaSuspicion = rateLimitMinSuspicion * 100
+ rateLimitMaxSuspicionPerSecond = 1
+
+ // Make this big enough to trigger suspicion limit in first blast.
+ concurrencyPoolSize = 30
+)
+
+// Session holds terminal metadata for operations.
+type Session struct {
+ sync.RWMutex
+
+ // Rate Limiting.
+
+ // started holds the unix timestamp in seconds when the session was started.
+ // It is set when the Session is created and may be treated as a constant.
+ started int64
+
+ // opCount is the amount of operations started (and not rate limited by suspicion).
+ opCount atomic.Int64
+
+ // suspicionScore holds a score of suspicious activity.
+ // Every suspicious operations is counted as at least 1.
+ // Rate limited operations because of suspicion are also counted as 1.
+ suspicionScore atomic.Int64
+
+ concurrencyPool chan struct{}
+}
+
+// SessionTerminal is an interface for terminals that support authorization.
+type SessionTerminal interface {
+ GetSession() *Session
+}
+
+// SessionAddOn can be inherited by terminals to add support for sessions.
+type SessionAddOn struct {
+ lock sync.Mutex
+
+ // session holds the terminal session.
+ session *Session
+}
+
+// GetSession returns the terminal's session.
+func (t *SessionAddOn) GetSession() *Session {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ // Create session if it does not exist.
+ if t.session == nil {
+ t.session = NewSession()
+ }
+
+ return t.session
+}
+
+// NewSession returns a new session.
+func NewSession() *Session {
+ return &Session{
+ started: time.Now().Unix() - 1, // Ensure a 1 second difference to current time.
+ concurrencyPool: make(chan struct{}, concurrencyPoolSize),
+ }
+}
+
+// RateLimitInfo returns some basic information about the status of the rate limiter.
+func (s *Session) RateLimitInfo() string {
+ secondsActive := time.Now().Unix() - s.started
+
+ return fmt.Sprintf(
+ "%do/s %ds/s %ds",
+ s.opCount.Load()/secondsActive,
+ s.suspicionScore.Load()/secondsActive,
+ secondsActive,
+ )
+}
+
+// RateLimit enforces a rate and suspicion limit.
+func (s *Session) RateLimit() *Error {
+ secondsActive := time.Now().Unix() - s.started
+
+ // Check the suspicion limit.
+ score := s.suspicionScore.Load()
+ if score > rateLimitMinSuspicion {
+ scorePerSecond := score / secondsActive
+ if scorePerSecond >= rateLimitMaxSuspicionPerSecond {
+ // Add current try to suspicion score.
+ s.suspicionScore.Add(1)
+
+ return ErrRateLimited
+ }
+
+ // Permanently rate limit if suspicion goes over the perma min limit and
+ // the suspicion score is greater than 80% of the operation count.
+ if score > rateLimitMinPermaSuspicion &&
+ score*5 > s.opCount.Load()*4 { // Think: 80*5 == 100*4
+ return ErrRateLimited
+ }
+ }
+
+ // Check the rate limit.
+ count := s.opCount.Add(1)
+ if count > rateLimitMinOps {
+ opsPerSecond := count / secondsActive
+ if opsPerSecond >= rateLimitMaxOpsPerSecond {
+ return ErrRateLimited
+ }
+ }
+
+ return nil
+}
+
+// Suspicion Factors.
+const (
+ SusFactorCommon = 1
+ SusFactorWeirdButOK = 5
+ SusFactorQuiteUnusual = 10
+ SusFactorMustBeMalicious = 100
+)
+
+// ReportSuspiciousActivity reports suspicious activity of the terminal.
+func (s *Session) ReportSuspiciousActivity(factor int64) {
+ s.suspicionScore.Add(factor)
+}
+
+// LimitConcurrency limits concurrent executions.
+// If over the limit, waiting goroutines are selected randomly.
+// It returns the context error if it was canceled.
+func (s *Session) LimitConcurrency(ctx context.Context, f func()) error {
+ // Wait for place in pool.
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case s.concurrencyPool <- struct{}{}:
+ // We added our entry to the pool, continue with execution.
+ }
+
+ // Drain own spot if pool after execution.
+ defer func() {
+ select {
+ case <-s.concurrencyPool:
+ // Own entry drained.
+ default:
+ // This should never happen, but let's play safe and not deadlock when pool is empty.
+ log.Warningf("spn/session: failed to drain own entry from concurrency pool")
+ }
+ }()
+
+ // Execute and return.
+ f()
+ return nil
+}
diff --git a/spn/terminal/session_test.go b/spn/terminal/session_test.go
new file mode 100644
index 00000000..e61d1f52
--- /dev/null
+++ b/spn/terminal/session_test.go
@@ -0,0 +1,94 @@
+package terminal
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRateLimit(t *testing.T) {
+ t.Parallel()
+
+ var tErr *Error
+ s := NewSession()
+
+ // Everything should be okay within the min limit.
+ for i := 0; i < rateLimitMinOps; i++ {
+ tErr = s.RateLimit()
+ if tErr != nil {
+ t.Error("should not rate limit within min limit")
+ }
+ }
+
+ // Somewhere here we should rate limiting.
+ for i := 0; i < rateLimitMaxOpsPerSecond; i++ {
+ tErr = s.RateLimit()
+ }
+ assert.ErrorIs(t, tErr, ErrRateLimited, "should rate limit")
+}
+
+func TestSuspicionLimit(t *testing.T) {
+ t.Parallel()
+
+ var tErr *Error
+ s := NewSession()
+
+ // Everything should be okay within the min limit.
+ for i := 0; i < rateLimitMinSuspicion; i++ {
+ tErr = s.RateLimit()
+ if tErr != nil {
+ t.Error("should not rate limit within min limit")
+ }
+ s.ReportSuspiciousActivity(SusFactorCommon)
+ }
+
+ // Somewhere here we should rate limiting.
+ for i := 0; i < rateLimitMaxSuspicionPerSecond; i++ {
+ s.ReportSuspiciousActivity(SusFactorCommon)
+ tErr = s.RateLimit()
+ }
+ if tErr == nil {
+ t.Error("should rate limit")
+ }
+}
+
+func TestConcurrencyLimit(t *testing.T) {
+ t.Parallel()
+
+ s := NewSession()
+ started := time.Now()
+ wg := sync.WaitGroup{}
+ workTime := 1 * time.Millisecond
+ workers := concurrencyPoolSize * 10
+
+ // Start many workers to test concurrency.
+ wg.Add(workers)
+ for i := 0; i < workers; i++ {
+ workerNum := i
+ go func() {
+ defer func() {
+ _ = recover()
+ }()
+ _ = s.LimitConcurrency(context.Background(), func() {
+ time.Sleep(workTime)
+ wg.Done()
+
+ // Panic sometimes.
+ if workerNum%concurrencyPoolSize == 0 {
+ panic("test")
+ }
+ })
+ }()
+ }
+
+ // Wait and check time needed.
+ wg.Wait()
+ if time.Since(started) < (time.Duration(workers) * workTime / concurrencyPoolSize) {
+ t.Errorf("workers were too quick - only took %s", time.Since(started))
+ } else {
+ t.Logf("workers were correctly limited - took %s", time.Since(started))
+ }
+}
diff --git a/spn/terminal/terminal.go b/spn/terminal/terminal.go
new file mode 100644
index 00000000..bbccad2f
--- /dev/null
+++ b/spn/terminal/terminal.go
@@ -0,0 +1,909 @@
+package terminal
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tevino/abool"
+
+ "github.com/safing/jess"
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/log"
+ "github.com/safing/portbase/modules"
+ "github.com/safing/portbase/rng"
+ "github.com/safing/portmaster/spn/cabin"
+ "github.com/safing/portmaster/spn/conf"
+)
+
+const (
+ timeoutTicks = 5
+
+ clientTerminalAbandonTimeout = 15 * time.Second
+ serverTerminalAbandonTimeout = 5 * time.Minute
+)
+
+// Terminal represents a terminal.
+type Terminal interface { //nolint:golint // Being explicit is helpful here.
+ // ID returns the terminal ID.
+ ID() uint32
+ // Ctx returns the terminal context.
+ Ctx() context.Context
+
+ // Deliver delivers a message to the terminal.
+ // Should not be overridden by implementations.
+ Deliver(msg *Msg) *Error
+ // Send is used by others to send a message through the terminal.
+ // Should not be overridden by implementations.
+ Send(msg *Msg, timeout time.Duration) *Error
+ // Flush sends all messages waiting in the terminal.
+ // Should not be overridden by implementations.
+ Flush(timeout time.Duration)
+
+ // StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data.
+ // Should not be overridden by implementations.
+ StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error
+ // StopOperation stops the given operation.
+ // Should not be overridden by implementations.
+ StopOperation(op Operation, err *Error)
+
+ // Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon().
+ // Should not be overridden by implementations.
+ Abandon(err *Error)
+ // HandleAbandon gives the terminal the ability to cleanly shut down.
+ // The terminal is still fully functional at this point.
+ // The returned error is the error to send to the other side.
+ // Should never be called directly. Call Abandon() instead.
+ // Meant to be overridden by implementations.
+ HandleAbandon(err *Error) (errorToSend *Error)
+ // HandleDestruction gives the terminal the ability to clean up.
+ // The terminal has already fully shut down at this point.
+ // Should never be called directly. Call Abandon() instead.
+ // Meant to be overridden by implementations.
+ HandleDestruction(err *Error)
+
+ // FmtID formats the terminal ID (including parent IDs).
+ // May be overridden by implementations.
+ FmtID() string
+}
+
+// TerminalBase contains the basic functions of a terminal.
+type TerminalBase struct { //nolint:golint,maligned // Being explicit is helpful here.
+ // TODO: Fix maligned.
+ Terminal // Interface check.
+
+ lock sync.RWMutex
+
+ // id is the underlying id of the Terminal.
+ id uint32
+ // parentID is the id of the parent component.
+ parentID string
+
+ // ext holds the extended terminal so that the base terminal can access custom functions.
+ ext Terminal
+ // sendQueue holds message to be sent.
+ sendQueue chan *Msg
+ // flowControl holds the flow control system.
+ flowControl FlowControl
+ // upstream represents the upstream (parent) terminal.
+ upstream Upstream
+
+ // deliverProxy is populated with the configured deliver function
+ deliverProxy func(msg *Msg) *Error
+ // recvProxy is populated with the configured recv function
+ recvProxy func() <-chan *Msg
+
+ // ctx is the context of the Terminal.
+ ctx context.Context
+ // cancelCtx cancels ctx.
+ cancelCtx context.CancelFunc
+
+ // waitForFlush signifies if sending should be delayed until the next call
+ // to Flush()
+ waitForFlush *abool.AtomicBool
+ // flush is used to send a finish function to the handler, which will write
+ // all pending messages and then call the received function.
+ flush chan func()
+ // idleTicker ticks for increasing and checking the idle counter.
+ idleTicker *time.Ticker
+ // idleCounter counts the ticks the terminal has been idle.
+ idleCounter *uint32
+
+ // jession is the jess session used for encryption.
+ jession *jess.Session
+ // jessionLock locks jession.
+ jessionLock sync.Mutex
+ // encryptionReady is set when the encryption is ready for sending messages.
+ encryptionReady chan struct{}
+ // identity is the identity used by a remote Terminal.
+ identity *cabin.Identity
+
+ // operations holds references to all active operations that require persistence.
+ operations map[uint32]Operation
+ // nextOpID holds the next operation ID.
+ nextOpID *uint32
+ // permission holds the permissions of the terminal.
+ permission Permission
+
+ // opts holds the terminal options. It must not be modified after the terminal
+ // has started.
+ opts *TerminalOpts
+
+ // lastUnknownOpID holds the operation ID of the last data message received
+ // for an unknown operation ID.
+ lastUnknownOpID uint32
+ // lastUnknownOpMsgs holds the amount of continuous data messages received
+ // for the operation ID in lastUnknownOpID.
+ lastUnknownOpMsgs uint32
+
+ // Abandoning indicates if the Terminal is being abandoned. The main handlers
+ // will keep running until the context has been canceled by the abandon
+ // procedure.
+ // No new operations should be started.
+ // Whoever initiates the abandoning must also start the abandon procedure.
+ Abandoning *abool.AtomicBool
+}
+
+func createTerminalBase(
+ ctx context.Context,
+ id uint32,
+ parentID string,
+ remote bool,
+ initMsg *TerminalOpts,
+ upstream Upstream,
+) (*TerminalBase, *Error) {
+ t := &TerminalBase{
+ id: id,
+ parentID: parentID,
+ sendQueue: make(chan *Msg),
+ upstream: upstream,
+ waitForFlush: abool.New(),
+ flush: make(chan func()),
+ idleTicker: time.NewTicker(time.Minute),
+ idleCounter: new(uint32),
+ encryptionReady: make(chan struct{}),
+ operations: make(map[uint32]Operation),
+ nextOpID: new(uint32),
+ opts: initMsg,
+ Abandoning: abool.New(),
+ }
+ // Stop ticking to disable timeout.
+ t.idleTicker.Stop()
+ // Shift next operation ID if remote.
+ if remote {
+ atomic.AddUint32(t.nextOpID, 4)
+ }
+ // Create context.
+ t.ctx, t.cancelCtx = context.WithCancel(ctx)
+
+ // Create flow control.
+ switch initMsg.FlowControl {
+ case FlowControlDFQ:
+ t.flowControl = NewDuplexFlowQueue(t.Ctx(), initMsg.FlowControlSize, t.submitToUpstream)
+ t.deliverProxy = t.flowControl.Deliver
+ t.recvProxy = t.flowControl.Receive
+ case FlowControlNone:
+ deliver := make(chan *Msg, initMsg.FlowControlSize)
+ t.deliverProxy = MakeDirectDeliveryDeliverFunc(ctx, deliver)
+ t.recvProxy = MakeDirectDeliveryRecvFunc(deliver)
+ case FlowControlDefault:
+ fallthrough
+ default:
+ return nil, ErrInternalError.With("unknown flow control type %d", initMsg.FlowControl)
+ }
+
+ return t, nil
+}
+
+// ID returns the Terminal's ID.
+func (t *TerminalBase) ID() uint32 {
+ return t.id
+}
+
+// Ctx returns the Terminal's context.
+func (t *TerminalBase) Ctx() context.Context {
+ return t.ctx
+}
+
+// SetTerminalExtension sets the Terminal's extension. This function is not
+// guarded and may only be used during initialization.
+func (t *TerminalBase) SetTerminalExtension(ext Terminal) {
+ t.ext = ext
+}
+
+// SetTimeout sets the Terminal's idle timeout duration.
+// It is broken down into slots internally.
+func (t *TerminalBase) SetTimeout(d time.Duration) {
+ t.idleTicker.Reset(d / timeoutTicks)
+}
+
+// Deliver on TerminalBase only exists to conform to the interface. It must be
+// overridden by an actual implementation.
+func (t *TerminalBase) Deliver(msg *Msg) *Error {
+ // Deliver via configured proxy.
+ err := t.deliverProxy(msg)
+ if err != nil {
+ msg.Finish()
+ }
+
+ return err
+}
+
+// StartWorkers starts the necessary workers to operate the Terminal.
+func (t *TerminalBase) StartWorkers(m *modules.Module, terminalName string) {
+ // Start terminal workers.
+ m.StartWorker(terminalName+" handler", t.Handler)
+ m.StartWorker(terminalName+" sender", t.Sender)
+
+ // Start any flow control workers.
+ if t.flowControl != nil {
+ t.flowControl.StartWorkers(m, terminalName)
+ }
+}
+
+const (
+ sendThresholdLength = 100 // bytes
+ sendMaxLength = 4000 // bytes
+ sendThresholdMaxWait = 20 * time.Millisecond
+)
+
+// Handler receives and handles messages and must be started as a worker in the
+// module where the Terminal is used.
+func (t *TerminalBase) Handler(_ context.Context) error {
+ defer t.Abandon(ErrInternalError.With("handler died"))
+
+ var msg *Msg
+ defer msg.Finish()
+
+ for {
+ select {
+ case <-t.ctx.Done():
+ // Call Abandon just in case.
+ // Normally, only the StopProcedure function should cancel the context.
+ t.Abandon(nil)
+ return nil // Controlled worker exit.
+
+ case <-t.idleTicker.C:
+ // If nothing happens for a while, end the session.
+ if atomic.AddUint32(t.idleCounter, 1) > timeoutTicks {
+ // Abandon the terminal and reset the counter.
+ t.Abandon(ErrNoActivity)
+ atomic.StoreUint32(t.idleCounter, 0)
+ }
+
+ case msg = <-t.recvProxy():
+ err := t.handleReceive(msg)
+ if err != nil {
+ t.Abandon(err.Wrap("failed to handle"))
+ return nil
+ }
+
+ // Register activity.
+ atomic.StoreUint32(t.idleCounter, 0)
+ }
+ }
+}
+
+// submit is used to send message from the terminal to upstream, including
+// going through flow control, if configured.
+// This function should be used to send message from the terminal to upstream.
+func (t *TerminalBase) submit(msg *Msg, timeout time.Duration) {
+ // Submit directly if no flow control is configured.
+ if t.flowControl == nil {
+ t.submitToUpstream(msg, timeout)
+ return
+ }
+
+ // Hand over to flow control.
+ err := t.flowControl.Send(msg, timeout)
+ if err != nil {
+ msg.Finish()
+ t.Abandon(err.Wrap("failed to submit to flow control"))
+ }
+}
+
+// submitToUpstream is used to directly submit messages to upstream.
+// This function should only be used by the flow control or submit function.
+func (t *TerminalBase) submitToUpstream(msg *Msg, timeout time.Duration) {
+ // Add terminal ID as flow ID.
+ msg.FlowID = t.ID()
+
+ // Debug unit leaks.
+ msg.debugWithCaller(2)
+
+ // Submit to upstream.
+ err := t.upstream.Send(msg, timeout)
+ if err != nil {
+ msg.Finish()
+ t.Abandon(err.Wrap("failed to submit to upstream"))
+ }
+}
+
+// Sender handles sending messages and must be started as a worker in the
+// module where the Terminal is used.
+func (t *TerminalBase) Sender(_ context.Context) error {
+ // Don't send messages, if the encryption is net yet set up.
+ // The server encryption session is only initialized with the first
+ // operative message, not on Terminal creation.
+ if t.opts.Encrypt {
+ select {
+ case <-t.ctx.Done():
+ // Call Abandon just in case.
+ // Normally, the only the StopProcedure function should cancel the context.
+ t.Abandon(nil)
+ return nil // Controlled worker exit.
+ case <-t.encryptionReady:
+ }
+ }
+
+ // Be sure to call Stop even in case of sudden death.
+ defer t.Abandon(ErrInternalError.With("sender died"))
+
+ var msgBufferMsg *Msg
+ var msgBufferLen int
+ var msgBufferLimitReached bool
+ var sendMsgs bool
+ var sendMaxWait *time.Timer
+ var flushFinished func()
+
+ // Finish any current unit when returning.
+ defer msgBufferMsg.Finish()
+
+ // Only receive message when not sending the current msg buffer.
+ sendQueueOpMsgs := func() <-chan *Msg {
+ // Don't handle more messages, if the buffer is full.
+ if msgBufferLimitReached {
+ return nil
+ }
+ return t.sendQueue
+ }
+
+ // Only wait for sending slot when the current msg buffer is ready to be sent.
+ readyToSend := func() <-chan struct{} {
+ switch {
+ case !sendMsgs:
+ // Wait until there is something to send.
+ return nil
+ case t.flowControl != nil:
+ // Let flow control decide when we are ready.
+ return t.flowControl.ReadyToSend()
+ default:
+ // Always ready.
+ return ready
+ }
+ }
+
+ // Calculate current max wait time to send the msg buffer.
+ getSendMaxWait := func() <-chan time.Time {
+ if sendMaxWait != nil {
+ return sendMaxWait.C
+ }
+ return nil
+ }
+
+handling:
+ for {
+ select {
+ case <-t.ctx.Done():
+ // Call Stop just in case.
+ // Normally, the only the StopProcedure function should cancel the context.
+ t.Abandon(nil)
+ return nil // Controlled worker exit.
+
+ case <-t.idleTicker.C:
+ // If nothing happens for a while, end the session.
+ if atomic.AddUint32(t.idleCounter, 1) > timeoutTicks {
+ // Abandon the terminal and reset the counter.
+ t.Abandon(ErrNoActivity)
+ atomic.StoreUint32(t.idleCounter, 0)
+ }
+
+ case msg := <-sendQueueOpMsgs():
+ if msg == nil {
+ continue handling
+ }
+
+ // Add unit to buffer unit, or use it as new buffer.
+ if msgBufferMsg != nil {
+ // Pack, append and finish additional message.
+ msgBufferMsg.Consume(msg)
+ } else {
+ // Pack operation message.
+ msg.Pack()
+ // Convert to message of terminal.
+ msgBufferMsg = msg
+ msgBufferMsg.FlowID = t.ID()
+ msgBufferMsg.Type = MsgTypeData
+ }
+ msgBufferLen += msg.Data.Length()
+
+ // Check if there is enough data to hit the sending threshold.
+ if msgBufferLen >= sendThresholdLength {
+ sendMsgs = true
+ } else if sendMaxWait == nil && t.waitForFlush.IsNotSet() {
+ sendMaxWait = time.NewTimer(sendThresholdMaxWait)
+ }
+
+ // Check if we have reached the maximum buffer size.
+ if msgBufferLen >= sendMaxLength {
+ msgBufferLimitReached = true
+ }
+
+ // Register activity.
+ atomic.StoreUint32(t.idleCounter, 0)
+
+ case <-getSendMaxWait():
+ // The timer for waiting for more data has ended.
+ // Send all available data if not forced to wait for a flush.
+ if t.waitForFlush.IsNotSet() {
+ sendMsgs = true
+ }
+
+ case newFlushFinishedFn := <-t.flush:
+ // We are flushing - stop waiting.
+ t.waitForFlush.UnSet()
+
+ // Signal immediately if msg buffer is empty.
+ if msgBufferLen == 0 {
+ newFlushFinishedFn()
+ } else {
+ // If there already is a flush finished function, stack them.
+ if flushFinished != nil {
+ stackedFlushFinishFn := flushFinished
+ flushFinished = func() {
+ stackedFlushFinishFn()
+ newFlushFinishedFn()
+ }
+ } else {
+ flushFinished = newFlushFinishedFn
+ }
+ }
+
+ // Force sending data now.
+ sendMsgs = true
+
+ case <-readyToSend():
+ // Reset sending flags.
+ sendMsgs = false
+ msgBufferLimitReached = false
+
+ // Send if there is anything to send.
+ var err *Error
+ if msgBufferLen > 0 {
+ // Update message type to include priority.
+ if msgBufferMsg.Type == MsgTypeData &&
+ msgBufferMsg.Unit.IsHighPriority() &&
+ t.opts.UsePriorityDataMsgs {
+ msgBufferMsg.Type = MsgTypePriorityData
+ }
+
+ // Wait for clearance on initial msg only.
+ msgBufferMsg.Unit.WaitForSlot()
+
+ err = t.sendOpMsgs(msgBufferMsg)
+ }
+
+ // Reset buffer.
+ msgBufferMsg = nil
+ msgBufferLen = 0
+
+ // Reset send wait timer.
+ if sendMaxWait != nil {
+ sendMaxWait.Stop()
+ sendMaxWait = nil
+ }
+
+ // Check if we are flushing and need to notify.
+ if flushFinished != nil {
+ flushFinished()
+ flushFinished = nil
+ }
+
+ // Handle error after state updates.
+ if err != nil {
+ t.Abandon(err.With("failed to send"))
+ continue handling
+ }
+ }
+ }
+}
+
+// WaitForFlush makes the terminal pause all sending until the next call to
+// Flush().
+func (t *TerminalBase) WaitForFlush() {
+ t.waitForFlush.Set()
+}
+
+// Flush sends all data waiting to be sent.
+func (t *TerminalBase) Flush(timeout time.Duration) {
+ // Create channel and function for notifying.
+ wait := make(chan struct{})
+ finished := func() {
+ close(wait)
+ }
+ // Request flush and return when stopping.
+ select {
+ case t.flush <- finished:
+ case <-t.Ctx().Done():
+ return
+ case <-TimedOut(timeout):
+ return
+ }
+ // Wait for flush to finish and return when stopping.
+ select {
+ case <-wait:
+ case <-t.Ctx().Done():
+ return
+ case <-TimedOut(timeout):
+ return
+ }
+
+ // Flush flow control, if configured.
+ if t.flowControl != nil {
+ t.flowControl.Flush(timeout)
+ }
+}
+
+func (t *TerminalBase) encrypt(c *container.Container) (*container.Container, *Error) {
+ if !t.opts.Encrypt {
+ return c, nil
+ }
+
+ t.jessionLock.Lock()
+ defer t.jessionLock.Unlock()
+
+ letter, err := t.jession.Close(c.CompileData())
+ if err != nil {
+ return nil, ErrIntegrity.With("failed to encrypt: %w", err)
+ }
+
+ encryptedData, err := letter.ToWire()
+ if err != nil {
+ return nil, ErrInternalError.With("failed to pack letter: %w", err)
+ }
+
+ return encryptedData, nil
+}
+
+func (t *TerminalBase) decrypt(c *container.Container) (*container.Container, *Error) {
+ if !t.opts.Encrypt {
+ return c, nil
+ }
+
+ t.jessionLock.Lock()
+ defer t.jessionLock.Unlock()
+
+ letter, err := jess.LetterFromWire(c)
+ if err != nil {
+ return nil, ErrMalformedData.With("failed to parse letter: %w", err)
+ }
+
+ // Setup encryption if not yet done.
+ if t.jession == nil {
+ if t.identity == nil {
+ return nil, ErrInternalError.With("missing identity for setting up incoming encryption")
+ }
+
+ // Create jess session.
+ t.jession, err = letter.WireCorrespondence(t.identity)
+ if err != nil {
+ return nil, ErrIntegrity.With("failed to initialize incoming encryption: %w", err)
+ }
+
+ // Don't need that anymore.
+ t.identity = nil
+
+ // Encryption is ready for sending.
+ close(t.encryptionReady)
+ }
+
+ decryptedData, err := t.jession.Open(letter)
+ if err != nil {
+ return nil, ErrIntegrity.With("failed to decrypt: %w", err)
+ }
+
+ return container.New(decryptedData), nil
+}
+
+func (t *TerminalBase) handleReceive(msg *Msg) *Error {
+ msg.Unit.WaitForSlot()
+ defer msg.Finish()
+
+ // Debugging:
+ // log.Errorf("spn/terminal %s handling tmsg: %s", t.FmtID(), spew.Sdump(c.CompileData()))
+
+ // Check if message is empty. This will be the case if a message was only
+ // for updated the available space of the flow queue.
+ if !msg.Data.HoldsData() {
+ return nil
+ }
+
+ // Decrypt if enabled.
+ var tErr *Error
+ msg.Data, tErr = t.decrypt(msg.Data)
+ if tErr != nil {
+ return tErr
+ }
+
+ // Handle operation messages.
+ for msg.Data.HoldsData() {
+ // Get next message length.
+ msgLength, err := msg.Data.GetNextN32()
+ if err != nil {
+ return ErrMalformedData.With("failed to get operation msg length: %w", err)
+ }
+ if msgLength == 0 {
+ // Remainder is padding.
+ // Padding can only be at the end of the segment.
+ t.handlePaddingMsg(msg.Data)
+ return nil
+ }
+
+ // Get op msg data.
+ msgData, err := msg.Data.GetAsContainer(int(msgLength))
+ if err != nil {
+ return ErrMalformedData.With("failed to get operation msg data (%d/%d bytes): %w", msg.Data.Length(), msgLength, err)
+ }
+
+ // Handle op msg.
+ if handleErr := t.handleOpMsg(msgData); handleErr != nil {
+ return handleErr
+ }
+ }
+
+ return nil
+}
+
+func (t *TerminalBase) handleOpMsg(data *container.Container) *Error {
+ // Debugging:
+ // log.Errorf("spn/terminal %s handling opmsg: %s", t.FmtID(), spew.Sdump(data.CompileData()))
+
+ // Parse message operation id, type.
+ opID, msgType, err := ParseIDType(data)
+ if err != nil {
+ return ErrMalformedData.With("failed to parse operation msg id/type: %w", err)
+ }
+
+ switch msgType {
+ case MsgTypeInit:
+ t.handleOperationStart(opID, data)
+
+ case MsgTypeData, MsgTypePriorityData:
+ op, ok := t.GetActiveOp(opID)
+ if ok && !op.Stopped() {
+ // Create message from data.
+ msg := NewEmptyMsg()
+ msg.FlowID = opID
+ msg.Type = msgType
+ msg.Data = data
+ if msg.Type == MsgTypePriorityData {
+ msg.Unit.MakeHighPriority()
+ }
+
+ // Deliver message to operation.
+ tErr := op.Deliver(msg)
+ if tErr != nil {
+ // Also stop on "success" errors!
+ msg.Finish()
+ t.StopOperation(op, tErr)
+ }
+ return nil
+ }
+
+ // If an active op is not found, this is likely just left-overs from a
+ // stopped or failed operation.
+ // log.Tracef("spn/terminal: %s received data msg for unknown op %d", fmtTerminalID(t.parentID, t.id), opID)
+
+ // Send a stop error if this happens too often.
+ if opID == t.lastUnknownOpID {
+ // OpID is the same as last time.
+ t.lastUnknownOpMsgs++
+
+ // Log an warning (via StopOperation) and send a stop message every thousand.
+ if t.lastUnknownOpMsgs%1000 == 0 {
+ t.StopOperation(newUnknownOp(opID, ""), ErrUnknownOperationID.With("received %d unsolicited data msgs", t.lastUnknownOpMsgs))
+ }
+
+ // TODO: Abandon terminal at over 10000?
+ } else {
+ // OpID changed, set new ID and reset counter.
+ t.lastUnknownOpID = opID
+ t.lastUnknownOpMsgs = 1
+ }
+
+ case MsgTypeStop:
+ // Parse received error.
+ opErr, parseErr := ParseExternalError(data.CompileData())
+ if parseErr != nil {
+ log.Warningf("spn/terminal: %s failed to parse stop error: %s", fmtTerminalID(t.parentID, t.id), parseErr)
+ opErr = ErrUnknownError.AsExternal()
+ }
+
+ // End operation.
+ op, ok := t.GetActiveOp(opID)
+ if ok {
+ t.StopOperation(op, opErr)
+ } else {
+ log.Tracef("spn/terminal: %s received stop msg for unknown op %d", fmtTerminalID(t.parentID, t.id), opID)
+ }
+
+ default:
+ log.Warningf("spn/terminal: %s received unexpected message type: %d", t.FmtID(), msgType)
+ return ErrUnexpectedMsgType
+ }
+
+ return nil
+}
+
+func (t *TerminalBase) handlePaddingMsg(c *container.Container) {
+ padding := c.GetAll()
+ if len(padding) > 0 {
+ rngFeeder.SupplyEntropyIfNeeded(padding, len(padding))
+ }
+}
+
+func (t *TerminalBase) sendOpMsgs(msg *Msg) *Error {
+ msg.Unit.WaitForSlot()
+
+ // Add Padding if needed.
+ if t.opts.Padding > 0 {
+ paddingNeeded := (int(t.opts.Padding) - msg.Data.Length()) % int(t.opts.Padding)
+ if paddingNeeded > 0 {
+ // Add padding message header.
+ msg.Data.Append([]byte{0})
+ paddingNeeded--
+
+ // Add needed padding data.
+ if paddingNeeded > 0 {
+ padding, err := rng.Bytes(paddingNeeded)
+ if err != nil {
+ log.Debugf("spn/terminal: %s failed to get random data, using zeros instead", t.FmtID())
+ padding = make([]byte, paddingNeeded)
+ }
+ msg.Data.Append(padding)
+ }
+ }
+ }
+
+ // Encrypt operative data.
+ var tErr *Error
+ msg.Data, tErr = t.encrypt(msg.Data)
+ if tErr != nil {
+ return tErr
+ }
+
+ // Send data.
+ t.submit(msg, 0)
+ return nil
+}
+
+// Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon().
+// Should not be overridden by implementations.
+func (t *TerminalBase) Abandon(err *Error) {
+ if t.Abandoning.SetToIf(false, true) {
+ module.StartWorker("terminal abandon procedure", func(_ context.Context) error {
+ t.handleAbandonProcedure(err)
+ return nil
+ })
+ }
+}
+
+// HandleAbandon gives the terminal the ability to cleanly shut down.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Abandon() instead.
+// Meant to be overridden by implementations.
+func (t *TerminalBase) HandleAbandon(err *Error) (errorToSend *Error) {
+ return err
+}
+
+// HandleDestruction gives the terminal the ability to clean up.
+// The terminal has already fully shut down at this point.
+// Should never be called directly. Call Abandon() instead.
+// Meant to be overridden by implementations.
+func (t *TerminalBase) HandleDestruction(err *Error) {}
+
+func (t *TerminalBase) handleAbandonProcedure(err *Error) {
+ // End all operations.
+ for _, op := range t.allOps() {
+ t.StopOperation(op, nil)
+ }
+
+ // Prepare timeouts for waiting for ops.
+ timeout := clientTerminalAbandonTimeout
+ if conf.PublicHub() {
+ timeout = serverTerminalAbandonTimeout
+ }
+ checkTicker := time.NewTicker(50 * time.Millisecond)
+ defer checkTicker.Stop()
+ abortWaiting := time.After(timeout)
+
+ // Wait for all operations to end.
+waitForOps:
+ for {
+ select {
+ case <-checkTicker.C:
+ if t.GetActiveOpCount() <= 0 {
+ break waitForOps
+ }
+ case <-abortWaiting:
+ log.Warningf(
+ "spn/terminal: terminal %s is continuing shutdown with %d active operations",
+ t.FmtID(),
+ t.GetActiveOpCount(),
+ )
+ break waitForOps
+ }
+ }
+
+ // Call operation stop handle function for proper shutdown cleaning up.
+ if t.ext != nil {
+ err = t.ext.HandleAbandon(err)
+ }
+
+ // Send error to the connected Operation, if the error is internal.
+ if !err.IsExternal() {
+ if err == nil {
+ err = ErrStopping
+ }
+
+ msg := NewMsg(err.Pack())
+ msg.FlowID = t.ID()
+ msg.Type = MsgTypeStop
+ t.submit(msg, 1*time.Second)
+ }
+
+ // If terminal was ended locally, send all data before abandoning.
+ // If terminal was ended remotely, don't bother sending remaining data.
+ if !err.IsExternal() {
+ // Flushing could mean sending a full buffer of 50000 packets.
+ t.Flush(5 * time.Minute)
+ }
+
+ // Stop all other connected workers.
+ t.cancelCtx()
+ t.idleTicker.Stop()
+
+ // Call operation destruction handle function for proper shutdown cleaning up.
+ if t.ext != nil {
+ t.ext.HandleDestruction(err)
+ }
+}
+
+func (t *TerminalBase) allOps() []Operation {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ ops := make([]Operation, 0, len(t.operations))
+ for _, op := range t.operations {
+ ops = append(ops, op)
+ }
+
+ return ops
+}
+
+// MakeDirectDeliveryDeliverFunc creates a submit upstream function with the
+// given delivery channel.
+func MakeDirectDeliveryDeliverFunc(
+ ctx context.Context,
+ deliver chan *Msg,
+) func(c *Msg) *Error {
+ return func(c *Msg) *Error {
+ select {
+ case deliver <- c:
+ return nil
+ case <-ctx.Done():
+ return ErrStopping
+ }
+ }
+}
+
+// MakeDirectDeliveryRecvFunc makes a delivery receive function with the given
+// delivery channel.
+func MakeDirectDeliveryRecvFunc(
+ deliver chan *Msg,
+) func() <-chan *Msg {
+ return func() <-chan *Msg {
+ return deliver
+ }
+}
diff --git a/spn/terminal/terminal_test.go b/spn/terminal/terminal_test.go
new file mode 100644
index 00000000..b458f696
--- /dev/null
+++ b/spn/terminal/terminal_test.go
@@ -0,0 +1,311 @@
+package terminal
+
+import (
+ "fmt"
+ "os"
+ "runtime/pprof"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/safing/portbase/container"
+ "github.com/safing/portmaster/spn/cabin"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+func TestTerminals(t *testing.T) {
+ t.Parallel()
+
+ identity, erro := cabin.CreateIdentity(module.Ctx, "test")
+ if erro != nil {
+ t.Fatalf("failed to create identity: %s", erro)
+ }
+
+ // Test without and with encryption.
+ for _, encrypt := range []bool{false, true} {
+ // Test with different flow controls.
+ for _, fc := range []struct {
+ flowControl FlowControlType
+ flowControlSize uint32
+ }{
+ {
+ flowControl: FlowControlNone,
+ flowControlSize: 5,
+ },
+ {
+ flowControl: FlowControlDFQ,
+ flowControlSize: defaultTestQueueSize,
+ },
+ } {
+ // Run tests with combined options.
+ testTerminals(t, identity, &TerminalOpts{
+ Encrypt: encrypt,
+ Padding: defaultTestPadding,
+ FlowControl: fc.flowControl,
+ FlowControlSize: fc.flowControlSize,
+ })
+ }
+ }
+}
+
+func testTerminals(t *testing.T, identity *cabin.Identity, terminalOpts *TerminalOpts) {
+ t.Helper()
+
+ // Prepare encryption.
+ var dstHub *hub.Hub
+ if terminalOpts.Encrypt {
+ dstHub = identity.Hub
+ } else {
+ identity = nil
+ }
+
+ // Create test terminals.
+ var term1 *TestTerminal
+ var term2 *TestTerminal
+ var initData *container.Container
+ var err *Error
+ term1, initData, err = NewLocalTestTerminal(
+ module.Ctx, 127, "c1", dstHub, terminalOpts, createForwardingUpstream(
+ t, "c1", "c2", func(msg *Msg) *Error {
+ return term2.Deliver(msg)
+ },
+ ),
+ )
+ if err != nil {
+ t.Fatalf("failed to create local terminal: %s", err)
+ }
+ term2, _, err = NewRemoteTestTerminal(
+ module.Ctx, 127, "c2", identity, initData, createForwardingUpstream(
+ t, "c2", "c1", func(msg *Msg) *Error {
+ return term1.Deliver(msg)
+ },
+ ),
+ )
+ if err != nil {
+ t.Fatalf("failed to create remote terminal: %s", err)
+ }
+
+ // Start testing with counters.
+ countToQueueSize := uint64(terminalOpts.FlowControlSize)
+ optionsSuffix := fmt.Sprintf(
+ "encrypt=%v,flowType=%d",
+ terminalOpts.Encrypt,
+ terminalOpts.FlowControl,
+ )
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlyup-flushing-waiting:" + optionsSuffix,
+ flush: true,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlyup-waiting:" + optionsSuffix,
+ serverCountTo: 10,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlyup-flushing:" + optionsSuffix,
+ flush: true,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlyup:" + optionsSuffix,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlydown-flushing-waiting:" + optionsSuffix,
+ flush: true,
+ clientCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlydown-waiting:" + optionsSuffix,
+ clientCountTo: 10,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlydown-flushing:" + optionsSuffix,
+ flush: true,
+ clientCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "onlydown:" + optionsSuffix,
+ clientCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "twoway-flushing-waiting:" + optionsSuffix,
+ flush: true,
+ clientCountTo: countToQueueSize * 2,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "twoway-waiting:" + optionsSuffix,
+ flush: true,
+ clientCountTo: 10,
+ serverCountTo: 10,
+ waitBetweenMsgs: sendThresholdMaxWait * 2,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "twoway-flushing:" + optionsSuffix,
+ flush: true,
+ clientCountTo: countToQueueSize * 2,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "twoway:" + optionsSuffix,
+ clientCountTo: countToQueueSize * 2,
+ serverCountTo: countToQueueSize * 2,
+ waitBetweenMsgs: time.Millisecond,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "stresstest-down:" + optionsSuffix,
+ clientCountTo: countToQueueSize * 1000,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "stresstest-up:" + optionsSuffix,
+ serverCountTo: countToQueueSize * 1000,
+ })
+
+ testTerminalWithCounters(t, term1, term2, &testWithCounterOpts{
+ testName: "stresstest-duplex:" + optionsSuffix,
+ clientCountTo: countToQueueSize * 1000,
+ serverCountTo: countToQueueSize * 1000,
+ })
+
+ // Clean up.
+ term1.Abandon(nil)
+ term2.Abandon(nil)
+
+ // Give some time for the last log messages and clean up.
+ time.Sleep(100 * time.Millisecond)
+}
+
+func createForwardingUpstream(t *testing.T, srcName, dstName string, deliverFunc func(*Msg) *Error) Upstream {
+ t.Helper()
+
+ return UpstreamSendFunc(func(msg *Msg, _ time.Duration) *Error {
+ // Fast track nil containers.
+ if msg == nil {
+ dErr := deliverFunc(msg)
+ if dErr != nil {
+ t.Errorf("%s>%s: failed to deliver nil msg to terminal: %s", srcName, dstName, dErr)
+ return dErr.With("failed to deliver nil msg to terminal")
+ }
+ return nil
+ }
+
+ // Log messages.
+ if logTestCraneMsgs {
+ t.Logf("%s>%s: %v\n", srcName, dstName, msg.Data.CompileData())
+ }
+
+ // Deliver to other terminal.
+ dErr := deliverFunc(msg)
+ if dErr != nil {
+ t.Errorf("%s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr)
+ return dErr.With("failed to deliver to terminal")
+ }
+
+ return nil
+ })
+}
+
+type testWithCounterOpts struct {
+ testName string
+ flush bool
+ clientCountTo uint64
+ serverCountTo uint64
+ waitBetweenMsgs time.Duration
+}
+
+func testTerminalWithCounters(t *testing.T, term1, term2 *TestTerminal, opts *testWithCounterOpts) {
+ t.Helper()
+
+ // Wait async for test to complete, print stack after timeout.
+ finished := make(chan struct{})
+ maxTestDuration := 60 * time.Second
+ go func() {
+ select {
+ case <-finished:
+ case <-time.After(maxTestDuration):
+ fmt.Printf("terminal test %s is taking more than %s, printing stack:\n", opts.testName, maxTestDuration)
+ _ = pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
+ os.Exit(1)
+ }
+ }()
+
+ t.Logf("starting terminal counter test %s", opts.testName)
+ defer t.Logf("stopping terminal counter test %s", opts.testName)
+
+ // Start counters.
+ counter, tErr := NewCounterOp(term1, CounterOpts{
+ ClientCountTo: opts.clientCountTo,
+ ServerCountTo: opts.serverCountTo,
+ Flush: opts.flush,
+ Wait: opts.waitBetweenMsgs,
+ })
+ if tErr != nil {
+ t.Fatalf("terminal test %s failed to start counter: %s", opts.testName, tErr)
+ }
+
+ // Wait until counters are done.
+ counter.Wait()
+ close(finished)
+
+ // Check for error.
+ if counter.Error != nil {
+ t.Fatalf("terminal test %s failed to count: %s", opts.testName, counter.Error)
+ }
+
+ // Log stats.
+ printCTStats(t, opts.testName, "term1", term1)
+ printCTStats(t, opts.testName, "term2", term2)
+
+ // Check if stats match, if DFQ is used on both sides.
+ dfq1, ok1 := term1.flowControl.(*DuplexFlowQueue)
+ dfq2, ok2 := term2.flowControl.(*DuplexFlowQueue)
+ if ok1 && ok2 &&
+ (atomic.LoadInt32(dfq1.sendSpace) != atomic.LoadInt32(dfq2.reportedSpace) ||
+ atomic.LoadInt32(dfq2.sendSpace) != atomic.LoadInt32(dfq1.reportedSpace)) {
+ t.Fatalf("terminal test %s has non-matching space counters", opts.testName)
+ }
+}
+
+func printCTStats(t *testing.T, testName, name string, term *TestTerminal) {
+ t.Helper()
+
+ dfq, ok := term.flowControl.(*DuplexFlowQueue)
+ if !ok {
+ return
+ }
+
+ t.Logf(
+ "%s: %s: sq=%d rq=%d sends=%d reps=%d",
+ testName,
+ name,
+ len(dfq.sendQueue),
+ len(dfq.recvQueue),
+ atomic.LoadInt32(dfq.sendSpace),
+ atomic.LoadInt32(dfq.reportedSpace),
+ )
+}
diff --git a/spn/terminal/testing.go b/spn/terminal/testing.go
new file mode 100644
index 00000000..22b12608
--- /dev/null
+++ b/spn/terminal/testing.go
@@ -0,0 +1,243 @@
+package terminal
+
+import (
+ "context"
+ "time"
+
+ "github.com/safing/portbase/container"
+ "github.com/safing/portbase/log"
+ "github.com/safing/portmaster/spn/cabin"
+ "github.com/safing/portmaster/spn/hub"
+)
+
+const (
+ defaultTestQueueSize = 16
+ defaultTestPadding = 8
+ logTestCraneMsgs = false
+)
+
+// TestTerminal is a terminal for running tests.
+type TestTerminal struct {
+ *TerminalBase
+}
+
+// NewLocalTestTerminal returns a new local test terminal.
+func NewLocalTestTerminal(
+ ctx context.Context,
+ id uint32,
+ parentID string,
+ remoteHub *hub.Hub,
+ initMsg *TerminalOpts,
+ upstream Upstream,
+) (*TestTerminal, *container.Container, *Error) {
+ // Create Terminal Base.
+ t, initData, err := NewLocalBaseTerminal(ctx, id, parentID, remoteHub, initMsg, upstream)
+ if err != nil {
+ return nil, nil, err
+ }
+ t.StartWorkers(module, "test terminal")
+
+ return &TestTerminal{t}, initData, nil
+}
+
+// NewRemoteTestTerminal returns a new remote test terminal.
+func NewRemoteTestTerminal(
+ ctx context.Context,
+ id uint32,
+ parentID string,
+ identity *cabin.Identity,
+ initData *container.Container,
+ upstream Upstream,
+) (*TestTerminal, *TerminalOpts, *Error) {
+ // Create Terminal Base.
+ t, initMsg, err := NewRemoteBaseTerminal(ctx, id, parentID, identity, initData, upstream)
+ if err != nil {
+ return nil, nil, err
+ }
+ t.StartWorkers(module, "test terminal")
+
+ return &TestTerminal{t}, initMsg, nil
+}
+
+type delayedMsg struct {
+ msg *Msg
+ timeout time.Duration
+ delayUntil time.Time
+}
+
+func createDelayingTestForwardingFunc(
+ srcName,
+ dstName string,
+ delay time.Duration,
+ delayQueueSize int,
+ deliverFunc func(msg *Msg, timeout time.Duration) *Error,
+) func(msg *Msg, timeout time.Duration) *Error {
+ // Return simple forward func if no delay is given.
+ if delay == 0 {
+ return func(msg *Msg, timeout time.Duration) *Error {
+ // Deliver to other terminal.
+ dErr := deliverFunc(msg, timeout)
+ if dErr != nil {
+ log.Errorf("spn/testing: %s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr)
+ return dErr
+ }
+ return nil
+ }
+ }
+
+ // If there is delay, create a delaying channel and handler.
+ delayedMsgs := make(chan *delayedMsg, delayQueueSize)
+ go func() {
+ for {
+ // Read from chan
+ msg := <-delayedMsgs
+ if msg == nil {
+ return
+ }
+
+ // Check if we need to wait.
+ waitFor := time.Until(msg.delayUntil)
+ if waitFor > 0 {
+ time.Sleep(waitFor)
+ }
+
+ // Deliver to other terminal.
+ dErr := deliverFunc(msg.msg, msg.timeout)
+ if dErr != nil {
+ log.Errorf("spn/testing: %s>%s: failed to deliver to terminal: %s", srcName, dstName, dErr)
+ }
+ }
+ }()
+
+ return func(msg *Msg, timeout time.Duration) *Error {
+ // Add msg to delaying msg channel.
+ delayedMsgs <- &delayedMsg{
+ msg: msg,
+ timeout: timeout,
+ delayUntil: time.Now().Add(delay),
+ }
+ return nil
+ }
+}
+
+// HandleAbandon gives the terminal the ability to cleanly shut down.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Abandon() instead.
+func (t *TestTerminal) HandleAbandon(err *Error) (errorToSend *Error) {
+ switch err {
+ case nil:
+ // nil means that the Terminal is being shutdown by the owner.
+ log.Tracef("spn/terminal: %s is closing", fmtTerminalID(t.parentID, t.id))
+ default:
+ // All other errors are faults.
+ log.Warningf("spn/terminal: %s: %s", fmtTerminalID(t.parentID, t.id), err)
+ }
+
+ return
+}
+
+// NewSimpleTestTerminalPair provides a simple conntected terminal pair for tests.
+func NewSimpleTestTerminalPair(delay time.Duration, delayQueueSize int, opts *TerminalOpts) (a, b *TestTerminal, err error) {
+ if opts == nil {
+ opts = &TerminalOpts{
+ Padding: defaultTestPadding,
+ FlowControl: FlowControlDFQ,
+ FlowControlSize: defaultTestQueueSize,
+ }
+ }
+
+ var initData *container.Container
+ var tErr *Error
+ a, initData, tErr = NewLocalTestTerminal(
+ module.Ctx, 127, "a", nil, opts, UpstreamSendFunc(createDelayingTestForwardingFunc(
+ "a", "b", delay, delayQueueSize, func(msg *Msg, timeout time.Duration) *Error {
+ return b.Deliver(msg)
+ },
+ )),
+ )
+ if tErr != nil {
+ return nil, nil, tErr.Wrap("failed to create local test terminal")
+ }
+ b, _, tErr = NewRemoteTestTerminal(
+ module.Ctx, 127, "b", nil, initData, UpstreamSendFunc(createDelayingTestForwardingFunc(
+ "b", "a", delay, delayQueueSize, func(msg *Msg, timeout time.Duration) *Error {
+ return a.Deliver(msg)
+ },
+ )),
+ )
+ if tErr != nil {
+ return nil, nil, tErr.Wrap("failed to create remote test terminal")
+ }
+
+ return a, b, nil
+}
+
+// BareTerminal is a bare terminal that just returns errors for testing.
+type BareTerminal struct{}
+
+var (
+ _ Terminal = &BareTerminal{}
+
+ errNotImplementedByBareTerminal = ErrInternalError.With("not implemented by bare terminal")
+)
+
+// ID returns the terminal ID.
+func (t *BareTerminal) ID() uint32 {
+ return 0
+}
+
+// Ctx returns the terminal context.
+func (t *BareTerminal) Ctx() context.Context {
+ return context.Background()
+}
+
+// Deliver delivers a message to the terminal.
+// Should not be overridden by implementations.
+func (t *BareTerminal) Deliver(msg *Msg) *Error {
+ return errNotImplementedByBareTerminal
+}
+
+// Send is used by others to send a message through the terminal.
+// Should not be overridden by implementations.
+func (t *BareTerminal) Send(msg *Msg, timeout time.Duration) *Error {
+ return errNotImplementedByBareTerminal
+}
+
+// Flush sends all messages waiting in the terminal.
+// Should not be overridden by implementations.
+func (t *BareTerminal) Flush(timeout time.Duration) {}
+
+// StartOperation starts the given operation by assigning it an ID and sending the given operation initialization data.
+// Should not be overridden by implementations.
+func (t *BareTerminal) StartOperation(op Operation, initData *container.Container, timeout time.Duration) *Error {
+ return errNotImplementedByBareTerminal
+}
+
+// StopOperation stops the given operation.
+// Should not be overridden by implementations.
+func (t *BareTerminal) StopOperation(op Operation, err *Error) {}
+
+// Abandon shuts down the terminal unregistering it from upstream and calling HandleAbandon().
+// Should not be overridden by implementations.
+func (t *BareTerminal) Abandon(err *Error) {}
+
+// HandleAbandon gives the terminal the ability to cleanly shut down.
+// The terminal is still fully functional at this point.
+// The returned error is the error to send to the other side.
+// Should never be called directly. Call Abandon() instead.
+// Meant to be overridden by implementations.
+func (t *BareTerminal) HandleAbandon(err *Error) (errorToSend *Error) {
+ return err
+}
+
+// HandleDestruction gives the terminal the ability to clean up.
+// The terminal has already fully shut down at this point.
+// Should never be called directly. Call Abandon() instead.
+// Meant to be overridden by implementations.
+func (t *BareTerminal) HandleDestruction(err *Error) {}
+
+// FmtID formats the terminal ID (including parent IDs).
+// May be overridden by implementations.
+func (t *BareTerminal) FmtID() string {
+ return "bare"
+}
diff --git a/spn/terminal/upstream.go b/spn/terminal/upstream.go
new file mode 100644
index 00000000..9dd27d43
--- /dev/null
+++ b/spn/terminal/upstream.go
@@ -0,0 +1,16 @@
+package terminal
+
+import "time"
+
+// Upstream defines the interface for upstream (parent) components.
+type Upstream interface {
+ Send(msg *Msg, timeout time.Duration) *Error
+}
+
+// UpstreamSendFunc is a helper to be able to satisfy the Upstream interface.
+type UpstreamSendFunc func(msg *Msg, timeout time.Duration) *Error
+
+// Send is used to send a message through this upstream.
+func (fn UpstreamSendFunc) Send(msg *Msg, timeout time.Duration) *Error {
+ return fn(msg, timeout)
+}
diff --git a/spn/test b/spn/test
new file mode 100755
index 00000000..2a443bb4
--- /dev/null
+++ b/spn/test
@@ -0,0 +1,168 @@
+#!/bin/bash
+
+warnings=0
+errors=0
+scripted=0
+goUp="\\e[1A"
+fullTestFlags="-short"
+install=0
+testonly=0
+
+function help {
+ echo "usage: $0 [command] [options]"
+ echo ""
+ echo "commands:"
+ echo " run baseline tests"
+ echo " full run full tests (ie. not short)"
+ echo " install install deps for running tests"
+ echo ""
+ echo "options:"
+ echo " --scripted don't jump console lines (still use colors)"
+ echo " --test-only run tests only, no linters"
+ echo " [package] run only on this package"
+}
+
+function run {
+ if [[ $scripted -eq 0 ]]; then
+ echo "[......] $*"
+ fi
+
+ # create tmpfile
+ tmpfile=$(mktemp)
+ # execute
+ $* >$tmpfile 2>&1
+ rc=$?
+ output=$(cat $tmpfile)
+
+ # check return code
+ if [[ $rc -eq 0 ]]; then
+ if [[ $output == *"[no test files]"* ]]; then
+ echo -e "${goUp}[\e[01;33mNOTEST\e[00m] $*"
+ warnings=$((warnings+1))
+ else
+ echo -ne "${goUp}[\e[01;32m OK \e[00m] "
+ if [[ $2 == "test" ]]; then
+ echo -n $*
+ echo -n ": "
+ echo $output | cut -f "3-" -d " "
+ else
+ echo $*
+ fi
+ fi
+ else
+ if [[ $output == *"build constraints exclude all Go files"* ]]; then
+ echo -e "${goUp}[ !=OS ] $*"
+ else
+ echo -e "${goUp}[\e[01;31m FAIL \e[00m] $*"
+ cat $tmpfile
+ errors=$((errors+1))
+ fi
+ fi
+
+ rm -f $tmpfile
+}
+
+# get and switch to script dir
+baseDir="$( cd "$(dirname "$0")" && pwd )"
+cd "$baseDir"
+
+# args
+while true; do
+ case "$1" in
+ "-h"|"help"|"--help")
+ help
+ exit 0
+ ;;
+ "--scripted")
+ scripted=1
+ goUp=""
+ shift 1
+ ;;
+ "--test-only")
+ testonly=1
+ shift 1
+ ;;
+ "install")
+ install=1
+ shift 1
+ ;;
+ "full")
+ fullTestFlags=""
+ shift 1
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+# check if $GOPATH/bin is in $PATH
+if [[ $PATH != *"$GOPATH/bin"* ]]; then
+ export PATH=$GOPATH/bin:$PATH
+fi
+
+# install
+if [[ $install -eq 1 ]]; then
+ echo "installing dependencies..."
+ # TODO: update golangci-lint version regularly
+ echo "$ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.44.0"
+ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.44.0
+ exit 0
+fi
+
+# check dependencies
+if [[ $(which go) == "" ]]; then
+ echo "go command not found"
+ exit 1
+fi
+if [[ $testonly -eq 0 ]]; then
+ if [[ $(which gofmt) == "" ]]; then
+ echo "gofmt command not found"
+ exit 1
+ fi
+ if [[ $(which golangci-lint) == "" ]]; then
+ echo "golangci-lint command not found"
+ echo "install with: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin vX.Y.Z"
+ echo "don't forget to specify the version you want"
+ echo "or run: ./test install"
+ echo ""
+ echo "alternatively, install the current dev version with: go get -u github.com/golangci/golangci-lint/cmd/golangci-lint"
+ exit 1
+ fi
+fi
+
+# target selection
+if [[ "$1" == "" ]]; then
+ # get all packages
+ packages=$(go list -e ./...)
+else
+ # single package testing
+ packages=$(go list -e)/$1
+ echo "note: only running tests for package $packages"
+fi
+
+# platform info
+platformInfo=$(go env GOOS GOARCH)
+echo "running tests for ${platformInfo//$'\n'/ }:"
+
+# run vet/test on packages
+for package in $packages; do
+ packagename=${package#github.com/safing/spn} #TODO: could be queried with `go list .`
+ packagename=${packagename#/}
+ echo ""
+ echo $package
+ if [[ $testonly -eq 0 ]]; then
+ run go vet $package
+ run golangci-lint run $packagename
+ fi
+ run go test -cover $fullTestFlags $package
+done
+
+echo ""
+if [[ $errors -gt 0 ]]; then
+ echo "failed with $errors errors and $warnings warnings"
+ exit 1
+else
+ echo "succeeded with $warnings warnings"
+ exit 0
+fi
diff --git a/spn/tools/Dockerfile b/spn/tools/Dockerfile
new file mode 100644
index 00000000..dbe39af1
--- /dev/null
+++ b/spn/tools/Dockerfile
@@ -0,0 +1,23 @@
+FROM alpine as builder
+
+# Ensure ca-certficates are up to date
+# RUN update-ca-certificates
+
+# Download and verify portmaster-start binary.
+RUN mkdir /init
+RUN wget https://updates.safing.io/linux_amd64/start/portmaster-start_v0-9-6 -O /init/portmaster-start
+COPY start-checksum.txt /init/start-checksum
+RUN cd /init && sha256sum -c /init/start-checksum
+RUN chmod 555 /init/portmaster-start
+
+# Use minimal image as base.
+FROM alpine
+
+# Copy the static executable.
+COPY --from=builder /init/portmaster-start /init/portmaster-start
+
+# Copy the init script
+COPY container-init.sh /init.sh
+
+# Run the hub.
+ENTRYPOINT ["/init.sh"]
diff --git a/spn/tools/container-init.sh b/spn/tools/container-init.sh
new file mode 100755
index 00000000..e5120872
--- /dev/null
+++ b/spn/tools/container-init.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+DATA="/data"
+START="/data/portmaster-start"
+INIT_START="/init/portmaster-start"
+
+# Set safe shell options.
+set -euf -o pipefail
+
+# Check if data dir is mounted.
+if [ ! -d $DATA ]; then
+ echo "Nothing mounted at $DATA, aborting."
+ exit 1
+fi
+
+# Copy init start to correct location, if not available.
+if [ ! -f $START ]; then
+ cp $INIT_START $START
+fi
+
+# Download updates.
+echo "running: $START update --data /data --intel-only"
+$START update --data /data --intel-only
+
+# Remove PID file, which could have been left after a crash.
+rm -f $DATA/hub-lock.pid
+
+# Always start the SPN Hub with the updated main start binary.
+echo "running: $START hub --data /data -- $@"
+$START hub --data /data -- $@
diff --git a/spn/tools/install.sh b/spn/tools/install.sh
new file mode 100755
index 00000000..e7cf8fd7
--- /dev/null
+++ b/spn/tools/install.sh
@@ -0,0 +1,326 @@
+#!/bin/sh
+#
+# This script should be run via curl as root:
+# sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh)"
+# or wget
+# sudo sh -c "$(wget -qO- https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh)"
+#
+# As an alternative, you can first download the install script and run it afterwards:
+# wget https://raw.githubusercontent.com/safing/portmaster/master/spn/tools/install-spn.sh
+# sudo sh ./install.sh
+#
+#
+set -e
+
+ARCH=
+INSTALLDIR=
+PMSTART=
+ENABLENOW=
+INSTALLSYSTEMD=
+SYSTEMDINSTALLPATH=
+
+apply_defaults() {
+ ARCH=${ARCH:-amd64}
+ INSTALLDIR=${INSTALLDIR:-/opt/safing/spn}
+ PMSTART=${PMSTART:-https://updates.safing.io/latest/linux_${ARCH}/start/portmaster-start}
+ SYSTEMDINSTALLPATH=${SYSTEMDINSTALLPATH:-/etc/systemd/system/spn.service}
+
+ if command_exists systemctl; then
+ INSTALLSYSTEMD=${INSTALLSYSTEMD:-yes}
+ ENABLENOW=${ENABLENOW:-yes}
+ else
+ INSTALLSYSTEMD=${INSTALLSYSTEMD:-no}
+ ENABLENOW=${ENABLENOW:-no}
+ fi
+
+ # The hostname may be freshly set, ensure the ENV variable is correct.
+ export HOSTNAME=$(hostname)
+}
+
+command_exists() {
+ command -v "$@" >/dev/null 2>&1
+}
+
+setup_tty() {
+ if [ -t 0 ]; then
+ interactive=yes
+ fi
+
+ if [ -t 1 ]; then
+ RED=$(printf '\033[31m')
+ GREEN=$(printf '\033[32m')
+ YELLOW=$(printf '\033[33m')
+ BLUE=$(printf '\033[34m')
+ BOLD=$(printf '\033[1m')
+ RESET=$(printf '\033[m')
+ else
+ RED=""
+ GREEN=""
+ YELLOW=""
+ BLUE=""
+ BOLD=""
+ RESET=""
+ fi
+}
+
+log() {
+ echo ${GREEN}${BOLD}"-> "${RESET}"$@" >&2
+}
+
+error() {
+ echo ${RED}"Error: $@"${RESET} >&2
+}
+
+warn() {
+ echo ${YELLOW}"warn: $@"${RESET} >&2
+}
+
+run_systemctl() {
+ systemctl $@ >/dev/null 2>&1
+}
+
+download_file() {
+ local src=$1
+ local dest=$2
+
+ if command_exists curl; then
+ curl --silent --fail --show-error --location --output $dest $src
+ elif command_exists wget; then
+ wget --quiet -O $dest $src
+ else
+ error "No suitable download command found, either curl or wget must be installed"
+ exit 1
+ fi
+}
+
+ensure_install_dir() {
+ log "Creating ${INSTALLDIR}"
+ mkdir -p ${INSTALLDIR}
+}
+
+download_pmstart() {
+ log "Downloading portmaster-start ..."
+ local dest="${INSTALLDIR}/portmaster-start"
+ if [ -f "${dest}" ]; then
+ warn "Overwriting existing portmaster-start at ${dest}"
+ fi
+
+ download_file ${PMSTART} ${dest}
+
+ log "Changing permissions"
+ chmod a+x ${dest}
+}
+
+download_updates() {
+ log "Downloading updates ..."
+ ${INSTALLDIR}/portmaster-start --data=${INSTALLDIR} update
+}
+
+setup_systemd() {
+ log "Installing systemd service unit ..."
+ if [ ! "${INSTALLSYSTEMD}" = "yes" ]; then
+ warn "Skipping setup of systemd service unit"
+ echo "To launch the hub, execute the following as root:"
+ echo ""
+ echo "${INSTALLDIR}/portmaster-start --data ${INSTALLDIR} hub"
+ echo ""
+ return
+ fi
+
+ if [ -f "${SYSTEMDINSTALLPATH}" ]; then
+ warn "Overwriting existing unit path"
+ fi
+
+ cat >${SYSTEMDINSTALLPATH} < " HOSTNAME
+ fi
+ if [ "${METRICS_COMMENT}" = "" ]; then
+ log "Please enter metrics comment:"
+ read -p "> " METRICS_COMMENT
+ fi
+}
+
+write_config_file() {
+ cat >${1} < /etc/sysctl.d/9999-spn-network-optimizing.conf
+# cat /etc/sysctl.d/9999-spn-network-optimizing.conf
+# sysctl -p /etc/sysctl.d/9999-spn-network-optimizing.conf
+
+# Provide adequate buffer memory.
+# net.ipv4.tcp_mem is in 4096-byte pages.
+net.core.rmem_max = 1073741824
+net.core.wmem_max = 1073741824
+net.core.rmem_default = 16777216
+net.core.wmem_default = 16777216
+net.ipv4.tcp_rmem = 4096 16777216 1073741824
+net.ipv4.tcp_wmem = 4096 16777216 1073741824
+net.ipv4.tcp_mem = 4194304 8388608 16777216
+net.ipv4.udp_rmem_min = 16777216
+net.ipv4.udp_wmem_min = 16777216
+
+# Enable TCP window scaling.
+net.ipv4.tcp_window_scaling = 1
+
+# Increase the length of the processor input queue
+net.core.netdev_max_backlog = 100000
+net.core.netdev_budget = 1000
+net.core.netdev_budget_usecs = 10000
+
+# Set better congestion control.
+net.ipv4.tcp_congestion_control = htcp
+
+# Turn off fancy stuff for more stability.
+net.ipv4.tcp_sack = 0
+net.ipv4.tcp_dsack = 0
+net.ipv4.tcp_fack = 0
+net.ipv4.tcp_timestamps = 0
+
+# Max reorders before slow start.
+net.ipv4.tcp_reordering = 3
+
+# Prefer low latency to higher throughput.
+# Disables IPv4 TCP prequeue processing.
+net.ipv4.tcp_low_latency = 1
+
+# Don't start slow.
+net.ipv4.tcp_slow_start_after_idle = 0
diff --git a/spn/unit/doc.go b/spn/unit/doc.go
new file mode 100644
index 00000000..9826a6ce
--- /dev/null
+++ b/spn/unit/doc.go
@@ -0,0 +1,13 @@
+// Package unit provides a "work unit" scheduling system for handling data sets that traverse multiple workers / goroutines.
+// The aim is to bind priority to a data set instead of a goroutine and split resources fairly among requests.
+//
+// Every "work" Unit is assigned an ever increasing ID and can be marked as "paused" or "high priority".
+// The Scheduler always gives a clearance up to a certain ID. All units below this ID may be processed.
+// High priority Units may always be processed.
+//
+// The Scheduler works with short slots and measures how many Units were finished in a slot.
+// The "slot pace" holds an indication of the current Unit finishing speed per slot. It is only changed slowly (but boosts if too far away) in order to keep stabilize the system.
+// The Scheduler then calculates the next unit ID limit to give clearance to for the next slot:
+//
+// "finished units" + "slot pace" + "paused units" - "fraction of high priority units"
+package unit
diff --git a/spn/unit/scheduler.go b/spn/unit/scheduler.go
new file mode 100644
index 00000000..0b5d6e11
--- /dev/null
+++ b/spn/unit/scheduler.go
@@ -0,0 +1,358 @@
+package unit
+
+import (
+ "context"
+ "errors"
+ "math"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/tevino/abool"
+)
+
+const (
+ defaultSlotDuration = 10 * time.Millisecond // 100 slots per second
+ defaultMinSlotPace = 100 // 10 000 pps
+
+ defaultWorkSlotPercentage = 0.7 // 70%
+ defaultSlotChangeRatePerStreak = 0.02 // 2%
+
+ defaultStatCycleDuration = 1 * time.Minute
+)
+
+// Scheduler creates and schedules units.
+// Must be created using NewScheduler().
+type Scheduler struct { //nolint:maligned
+ // Configuration.
+ config SchedulerConfig
+
+ // Units IDs Limit / Thresholds.
+
+ // currentUnitID holds the last assigned Unit ID.
+ currentUnitID atomic.Int64
+ // clearanceUpTo holds the current threshold up to which Unit ID Units may be processed.
+ clearanceUpTo atomic.Int64
+ // slotPace holds the current pace. This is the base value for clearance
+ // calculation, not the value of the current cleared Units itself.
+ slotPace atomic.Int64
+ // finished holds the amount of units that were finished within the current slot.
+ finished atomic.Int64
+
+ // Slot management.
+ slotSignalA chan struct{}
+ slotSignalB chan struct{}
+ slotSignalSwitch bool
+ slotSignalsLock sync.RWMutex
+
+ stopping abool.AtomicBool
+ unitDebugger *UnitDebugger
+
+ // Stats.
+ stats struct {
+ // Working Values.
+ progress struct {
+ maxPace atomic.Int64
+ maxLeveledPace atomic.Int64
+ avgPaceSum atomic.Int64
+ avgPaceCnt atomic.Int64
+ avgUnitLifeSum atomic.Int64
+ avgUnitLifeCnt atomic.Int64
+ avgWorkSlotSum atomic.Int64
+ avgWorkSlotCnt atomic.Int64
+ avgCatchUpSlotSum atomic.Int64
+ avgCatchUpSlotCnt atomic.Int64
+ }
+
+ // Calculated Values.
+ current struct {
+ maxPace atomic.Int64
+ maxLeveledPace atomic.Int64
+ avgPace atomic.Int64
+ avgUnitLife atomic.Int64
+ avgWorkSlot atomic.Int64
+ avgCatchUpSlot atomic.Int64
+ }
+ }
+}
+
+// SchedulerConfig holds scheduler configuration.
+type SchedulerConfig struct {
+ // SlotDuration defines the duration of one slot.
+ SlotDuration time.Duration
+
+ // MinSlotPace defines the minimum slot pace.
+ // The slot pace will never fall below this value.
+ MinSlotPace int64
+
+ // WorkSlotPercentage defines the how much of a slot should be scheduled with work.
+ // The remainder is for catching up and breathing room for other tasks.
+ // Must be between 55% (0.55) and 95% (0.95).
+ // The default value is 0.7 (70%).
+ WorkSlotPercentage float64
+
+ // SlotChangeRatePerStreak defines how many percent (0-1) the slot pace
+ // should change per streak.
+ // Is enforced to be able to change the minimum slot pace by at least 1.
+ // The default value is 0.02 (2%).
+ SlotChangeRatePerStreak float64
+
+ // StatCycleDuration defines how often stats are calculated.
+ // The default value is 1 minute.
+ StatCycleDuration time.Duration
+}
+
+// NewScheduler returns a new scheduler.
+func NewScheduler(config *SchedulerConfig) *Scheduler {
+ // Fallback to empty config if none is given.
+ if config == nil {
+ config = &SchedulerConfig{}
+ }
+
+ // Create new scheduler.
+ s := &Scheduler{
+ config: *config,
+ slotSignalA: make(chan struct{}),
+ slotSignalB: make(chan struct{}),
+ }
+
+ // Fill in defaults.
+ if s.config.SlotDuration == 0 {
+ s.config.SlotDuration = defaultSlotDuration
+ }
+ if s.config.MinSlotPace == 0 {
+ s.config.MinSlotPace = defaultMinSlotPace
+ }
+ if s.config.WorkSlotPercentage == 0 {
+ s.config.WorkSlotPercentage = defaultWorkSlotPercentage
+ }
+ if s.config.SlotChangeRatePerStreak == 0 {
+ s.config.SlotChangeRatePerStreak = defaultSlotChangeRatePerStreak
+ }
+ if s.config.StatCycleDuration == 0 {
+ s.config.StatCycleDuration = defaultStatCycleDuration
+ }
+
+ // Check boundaries of WorkSlotPercentage.
+ switch {
+ case s.config.WorkSlotPercentage < 0.55:
+ s.config.WorkSlotPercentage = 0.55
+ case s.config.WorkSlotPercentage > 0.95:
+ s.config.WorkSlotPercentage = 0.95
+ }
+
+ // The slot change rate must be able to change the slot pace by at least 1.
+ if s.config.SlotChangeRatePerStreak < (1 / float64(s.config.MinSlotPace)) {
+ s.config.SlotChangeRatePerStreak = (1 / float64(s.config.MinSlotPace))
+
+ // Debug logging:
+ // fmt.Printf("--- increased SlotChangeRatePerStreak to %f\n", s.config.SlotChangeRatePerStreak)
+ }
+
+ // Initialize scheduler fields.
+ s.clearanceUpTo.Store(s.config.MinSlotPace)
+ s.slotPace.Store(s.config.MinSlotPace)
+
+ return s
+}
+
+func (s *Scheduler) nextSlotSignal() chan struct{} {
+ s.slotSignalsLock.RLock()
+ defer s.slotSignalsLock.RUnlock()
+
+ if s.slotSignalSwitch {
+ return s.slotSignalA
+ }
+ return s.slotSignalB
+}
+
+func (s *Scheduler) announceNextSlot() {
+ s.slotSignalsLock.Lock()
+ defer s.slotSignalsLock.Unlock()
+
+ // Close new slot signal and refresh previous one.
+ if s.slotSignalSwitch {
+ close(s.slotSignalA)
+ s.slotSignalB = make(chan struct{})
+ } else {
+ close(s.slotSignalB)
+ s.slotSignalA = make(chan struct{})
+ }
+
+ // Switch to next slot.
+ s.slotSignalSwitch = !s.slotSignalSwitch
+}
+
+// SlotScheduler manages the slot and schedules units.
+// Must only be started once.
+func (s *Scheduler) SlotScheduler(ctx context.Context) error {
+ // Start slot ticker.
+ ticker := time.NewTicker(s.config.SlotDuration / 2)
+ defer ticker.Stop()
+
+ // Give clearance to all when stopping.
+ defer s.clearanceUpTo.Store(math.MaxInt64 - math.MaxInt32)
+
+ var (
+ halfSlotID uint64
+ halfSlotStartedAt = time.Now()
+ halfSlotEndedAt time.Time
+ halfSlotDuration = float64(s.config.SlotDuration / 2)
+
+ increaseStreak float64
+ decreaseStreak float64
+ oneStreaks int
+
+ cycleStatsAt = uint64(s.config.StatCycleDuration / (s.config.SlotDuration / 2))
+ )
+
+ for range ticker.C {
+ halfSlotEndedAt = time.Now()
+
+ switch {
+ case halfSlotID%2 == 0:
+
+ // First Half-Slot: Work Slot
+
+ // Calculate time taken in previous slot.
+ catchUpSlotDuration := halfSlotEndedAt.Sub(halfSlotStartedAt).Nanoseconds()
+
+ // Add current slot duration to avg calculation.
+ s.stats.progress.avgCatchUpSlotCnt.Add(1)
+ if s.stats.progress.avgCatchUpSlotSum.Add(catchUpSlotDuration) < 0 {
+ // Reset if we wrap.
+ s.stats.progress.avgCatchUpSlotCnt.Store(1)
+ s.stats.progress.avgCatchUpSlotSum.Store(catchUpSlotDuration)
+ }
+
+ // Reset slot counters.
+ s.finished.Store(0)
+
+ // Raise clearance according
+ s.clearanceUpTo.Store(
+ s.currentUnitID.Load() +
+ int64(
+ float64(s.slotPace.Load())*s.config.WorkSlotPercentage,
+ ),
+ )
+
+ // Announce start of new slot.
+ s.announceNextSlot()
+
+ default:
+
+ // Second Half-Slot: Catch-Up Slot
+
+ // Calculate time taken in previous slot.
+ workSlotDuration := halfSlotEndedAt.Sub(halfSlotStartedAt).Nanoseconds()
+
+ // Add current slot duration to avg calculation.
+ s.stats.progress.avgWorkSlotCnt.Add(1)
+ if s.stats.progress.avgWorkSlotSum.Add(workSlotDuration) < 0 {
+ // Reset if we wrap.
+ s.stats.progress.avgWorkSlotCnt.Store(1)
+ s.stats.progress.avgWorkSlotSum.Store(workSlotDuration)
+ }
+
+ // Calculate slot duration skew correction, as slots will not run in the
+ // exact specified duration.
+ slotDurationSkewCorrection := halfSlotDuration / float64(workSlotDuration)
+
+ // Calculate slot pace with performance of first half-slot.
+ // Get current slot pace as float64.
+ currentSlotPace := float64(s.slotPace.Load())
+ // Calculate current raw slot pace.
+ newRawSlotPace := float64(s.finished.Load()*2) * slotDurationSkewCorrection
+
+ // Move slot pace in the trending direction.
+ if newRawSlotPace >= currentSlotPace {
+ // Adjust based on streak.
+ increaseStreak++
+ decreaseStreak = 0
+ s.slotPace.Add(int64(
+ currentSlotPace * s.config.SlotChangeRatePerStreak * increaseStreak,
+ ))
+
+ // Count one-streaks.
+ if increaseStreak == 1 {
+ oneStreaks++
+ } else {
+ oneStreaks = 0
+ }
+
+ // Debug logging:
+ // fmt.Printf("+++ slot pace: %.0f (current raw pace: %.0f, increaseStreak: %.0f, clearanceUpTo: %d)\n", currentSlotPace, newRawSlotPace, increaseStreak, s.clearanceUpTo.Load())
+ } else {
+ // Adjust based on streak.
+ decreaseStreak++
+ increaseStreak = 0
+ s.slotPace.Add(int64(
+ -currentSlotPace * s.config.SlotChangeRatePerStreak * decreaseStreak,
+ ))
+
+ // Enforce minimum.
+ if s.slotPace.Load() < s.config.MinSlotPace {
+ s.slotPace.Store(s.config.MinSlotPace)
+ decreaseStreak = 0
+ }
+
+ // Count one-streaks.
+ if decreaseStreak == 1 {
+ oneStreaks++
+ } else {
+ oneStreaks = 0
+ }
+
+ // Debug logging:
+ // fmt.Printf("--- slot pace: %.0f (current raw pace: %.0f, decreaseStreak: %.0f, clearanceUpTo: %d)\n", currentSlotPace, newRawSlotPace, decreaseStreak, s.clearanceUpTo.Load())
+ }
+
+ // Record Stats
+
+ // Add current pace to avg calculation.
+ s.stats.progress.avgPaceCnt.Add(1)
+ if s.stats.progress.avgPaceSum.Add(s.slotPace.Load()) < 0 {
+ // Reset if we wrap.
+ s.stats.progress.avgPaceCnt.Store(1)
+ s.stats.progress.avgPaceSum.Store(s.slotPace.Load())
+ }
+
+ // Check if current pace is new max.
+ if s.slotPace.Load() > s.stats.progress.maxPace.Load() {
+ s.stats.progress.maxPace.Store(s.slotPace.Load())
+ }
+
+ // Check if current pace is new leveled max
+ if oneStreaks >= 3 && s.slotPace.Load() > s.stats.progress.maxLeveledPace.Load() {
+ s.stats.progress.maxLeveledPace.Store(s.slotPace.Load())
+ }
+ }
+ // Switch to other slot-half.
+ halfSlotID++
+ halfSlotStartedAt = halfSlotEndedAt
+
+ // Cycle stats after defined time period.
+ if halfSlotID%cycleStatsAt == 0 {
+ s.cycleStats()
+ }
+
+ // Check if we are stopping.
+ select {
+ case <-ctx.Done():
+ return nil
+ default:
+ }
+ if s.stopping.IsSet() {
+ return nil
+ }
+ }
+
+ // We should never get here.
+ // If we do, trigger a worker restart via the service worker.
+ return errors.New("unexpected end of scheduler")
+}
+
+// Stop stops the scheduler and gives clearance to all units.
+func (s *Scheduler) Stop() {
+ s.stopping.Set()
+}
diff --git a/spn/unit/scheduler_stats.go b/spn/unit/scheduler_stats.go
new file mode 100644
index 00000000..6fd1d272
--- /dev/null
+++ b/spn/unit/scheduler_stats.go
@@ -0,0 +1,87 @@
+package unit
+
+// Stats are somewhat racy, as one value of sum or count might already be
+// updated with the latest slot data, while the other has been not.
+// This is not so much of a problem, as slots are really short and the impact
+// is very low.
+
+// cycleStats calculates the new values and cycles the current values.
+func (s *Scheduler) cycleStats() {
+ // Get and reset max pace.
+ s.stats.current.maxPace.Store(s.stats.progress.maxPace.Load())
+ s.stats.progress.maxPace.Store(0)
+
+ // Get and reset max leveled pace.
+ s.stats.current.maxLeveledPace.Store(s.stats.progress.maxLeveledPace.Load())
+ s.stats.progress.maxLeveledPace.Store(0)
+
+ // Get and reset avg slot pace.
+ avgPaceCnt := s.stats.progress.avgPaceCnt.Load()
+ if avgPaceCnt > 0 {
+ s.stats.current.avgPace.Store(s.stats.progress.avgPaceSum.Load() / avgPaceCnt)
+ } else {
+ s.stats.current.avgPace.Store(0)
+ }
+ s.stats.progress.avgPaceCnt.Store(0)
+ s.stats.progress.avgPaceSum.Store(0)
+
+ // Get and reset avg unit life.
+ avgUnitLifeCnt := s.stats.progress.avgUnitLifeCnt.Load()
+ if avgUnitLifeCnt > 0 {
+ s.stats.current.avgUnitLife.Store(s.stats.progress.avgUnitLifeSum.Load() / avgUnitLifeCnt)
+ } else {
+ s.stats.current.avgUnitLife.Store(0)
+ }
+ s.stats.progress.avgUnitLifeCnt.Store(0)
+ s.stats.progress.avgUnitLifeSum.Store(0)
+
+ // Get and reset avg work slot duration.
+ avgWorkSlotCnt := s.stats.progress.avgWorkSlotCnt.Load()
+ if avgWorkSlotCnt > 0 {
+ s.stats.current.avgWorkSlot.Store(s.stats.progress.avgWorkSlotSum.Load() / avgWorkSlotCnt)
+ } else {
+ s.stats.current.avgWorkSlot.Store(0)
+ }
+ s.stats.progress.avgWorkSlotCnt.Store(0)
+ s.stats.progress.avgWorkSlotSum.Store(0)
+
+ // Get and reset avg catch up slot duration.
+ avgCatchUpSlotCnt := s.stats.progress.avgCatchUpSlotCnt.Load()
+ if avgCatchUpSlotCnt > 0 {
+ s.stats.current.avgCatchUpSlot.Store(s.stats.progress.avgCatchUpSlotSum.Load() / avgCatchUpSlotCnt)
+ } else {
+ s.stats.current.avgCatchUpSlot.Store(0)
+ }
+ s.stats.progress.avgCatchUpSlotCnt.Store(0)
+ s.stats.progress.avgCatchUpSlotSum.Store(0)
+}
+
+// GetMaxSlotPace returns the current maximum slot pace.
+func (s *Scheduler) GetMaxSlotPace() int64 {
+ return s.stats.current.maxPace.Load()
+}
+
+// GetMaxLeveledSlotPace returns the current maximum leveled slot pace.
+func (s *Scheduler) GetMaxLeveledSlotPace() int64 {
+ return s.stats.current.maxLeveledPace.Load()
+}
+
+// GetAvgSlotPace returns the current average slot pace.
+func (s *Scheduler) GetAvgSlotPace() int64 {
+ return s.stats.current.avgPace.Load()
+}
+
+// GetAvgUnitLife returns the current average unit lifetime until it is finished.
+func (s *Scheduler) GetAvgUnitLife() int64 {
+ return s.stats.current.avgUnitLife.Load()
+}
+
+// GetAvgWorkSlotDuration returns the current average work slot duration.
+func (s *Scheduler) GetAvgWorkSlotDuration() int64 {
+ return s.stats.current.avgWorkSlot.Load()
+}
+
+// GetAvgCatchUpSlotDuration returns the current average catch up slot duration.
+func (s *Scheduler) GetAvgCatchUpSlotDuration() int64 {
+ return s.stats.current.avgCatchUpSlot.Load()
+}
diff --git a/spn/unit/scheduler_test.go b/spn/unit/scheduler_test.go
new file mode 100644
index 00000000..3e3ec6ba
--- /dev/null
+++ b/spn/unit/scheduler_test.go
@@ -0,0 +1,51 @@
+package unit
+
+import (
+ "context"
+ "testing"
+)
+
+func BenchmarkScheduler(b *testing.B) {
+ workers := 10
+
+ // Create and start scheduler.
+ s := NewScheduler(&SchedulerConfig{})
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ err := s.SlotScheduler(ctx)
+ if err != nil {
+ panic(err)
+ }
+ }()
+ defer cancel()
+
+ // Init control structures.
+ done := make(chan struct{})
+ finishedCh := make(chan struct{})
+
+ // Start workers.
+ for i := 0; i < workers; i++ {
+ go func() {
+ for {
+ u := s.NewUnit()
+ u.WaitForSlot()
+ u.Finish()
+ select {
+ case finishedCh <- struct{}{}:
+ case <-done:
+ return
+ }
+ }
+ }()
+ }
+
+ // Start benchmark.
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ <-finishedCh
+ }
+ b.StopTimer()
+
+ // Cleanup.
+ close(done)
+}
diff --git a/spn/unit/unit.go b/spn/unit/unit.go
new file mode 100644
index 00000000..d198fd64
--- /dev/null
+++ b/spn/unit/unit.go
@@ -0,0 +1,103 @@
+package unit
+
+import (
+ "time"
+
+ "github.com/tevino/abool"
+)
+
+// Unit describes a "work unit" and is meant to be embedded into another struct
+// used for passing data moving through multiple processing steps.
+type Unit struct {
+ id int64
+ scheduler *Scheduler
+ created time.Time
+ finished abool.AtomicBool
+ highPriority abool.AtomicBool
+}
+
+// NewUnit returns a new unit within the scheduler.
+func (s *Scheduler) NewUnit() *Unit {
+ return &Unit{
+ id: s.currentUnitID.Add(1),
+ scheduler: s,
+ created: time.Now(),
+ }
+}
+
+// ReUse re-initialized the unit to be able to reuse already allocated structs.
+func (u *Unit) ReUse() {
+ // Finish previous unit.
+ u.Finish()
+
+ // Get new ID and unset finish flag.
+ u.id = u.scheduler.currentUnitID.Add(1)
+ u.finished.UnSet()
+}
+
+// WaitForSlot blocks until the unit may be processed.
+func (u *Unit) WaitForSlot() {
+ // High priority units may always process.
+ if u.highPriority.IsSet() {
+ return
+ }
+
+ for {
+ // Check if we are allowed to process in the current slot.
+ if u.id <= u.scheduler.clearanceUpTo.Load() {
+ return
+ }
+
+ // Debug logging:
+ // fmt.Printf("unit %d waiting for clearance at %d\n", u.id, u.scheduler.clearanceUpTo.Load())
+
+ // Wait for next slot.
+ <-u.scheduler.nextSlotSignal()
+ }
+}
+
+// Finish signals the unit scheduler that this unit has finished processing.
+// Will no-op if called on a nil Unit.
+func (u *Unit) Finish() {
+ if u == nil {
+ return
+ }
+
+ // Always increase finished, even if the unit is from a previous epoch.
+ if u.finished.SetToIf(false, true) {
+ u.scheduler.finished.Add(1)
+
+ // Record the time this unit took from creation to finish.
+ timeTaken := time.Since(u.created).Nanoseconds()
+ u.scheduler.stats.progress.avgUnitLifeCnt.Add(1)
+ if u.scheduler.stats.progress.avgUnitLifeSum.Add(timeTaken) < 0 {
+ // Reset if we wrap.
+ u.scheduler.stats.progress.avgUnitLifeCnt.Store(1)
+ u.scheduler.stats.progress.avgUnitLifeSum.Store(timeTaken)
+ }
+ }
+}
+
+// MakeHighPriority marks the unit as high priority.
+func (u *Unit) MakeHighPriority() {
+ switch {
+ case u.finished.IsSet():
+ // Unit is already finished.
+ case !u.highPriority.SetToIf(false, true):
+ // Unit is already set to high priority.
+ // Else: High Priority set.
+ case u.id > u.scheduler.clearanceUpTo.Load():
+ // Unit is outside current clearance, reduce clearance by one.
+ u.scheduler.clearanceUpTo.Add(-1)
+ }
+}
+
+// IsHighPriority returns whether the unit has high priority.
+func (u *Unit) IsHighPriority() bool {
+ return u.highPriority.IsSet()
+}
+
+// RemovePriority removes the high priority mark.
+func (u *Unit) RemovePriority() {
+ u.highPriority.UnSet()
+}
diff --git a/spn/unit/unit_debug.go b/spn/unit/unit_debug.go
new file mode 100644
index 00000000..0ba053bd
--- /dev/null
+++ b/spn/unit/unit_debug.go
@@ -0,0 +1,86 @@
+package unit
+
+import (
+ "sync"
+ "time"
+
+ "github.com/safing/portbase/log"
+)
+
+// UnitDebugger is used to debug unit leaks.
+type UnitDebugger struct { //nolint:golint
+ units map[int64]*UnitDebugData
+ unitsLock sync.Mutex
+}
+
+// UnitDebugData represents a unit that is being debugged.
+type UnitDebugData struct { //nolint:golint
+ unit *Unit
+ unitSource string
+}
+
+// DebugUnit registers the given unit for debug output with the given source.
+// Additional calls on the same unit update the unit source.
+// StartDebugLog() must be called before calling DebugUnit().
+func (s *Scheduler) DebugUnit(u *Unit, unitSource string) {
+ // Check if scheduler and unit debugger are created.
+ if s == nil || s.unitDebugger == nil {
+ return
+ }
+
+ s.unitDebugger.unitsLock.Lock()
+ defer s.unitDebugger.unitsLock.Unlock()
+
+ s.unitDebugger.units[u.id] = &UnitDebugData{
+ unit: u,
+ unitSource: unitSource,
+ }
+}
+
+// StartDebugLog logs the scheduler state every second.
+func (s *Scheduler) StartDebugLog() {
+ s.unitDebugger = &UnitDebugger{
+ units: make(map[int64]*UnitDebugData),
+ }
+
+ // Force StatCycleDuration to match the debug log output.
+ s.config.StatCycleDuration = time.Second
+
+ go func() {
+ for {
+ s.debugStep()
+ time.Sleep(time.Second)
+ }
+ }()
+}
+
+func (s *Scheduler) debugStep() {
+ s.unitDebugger.unitsLock.Lock()
+ defer s.unitDebugger.unitsLock.Unlock()
+
+ // Go through debugging units and clear finished ones, count sources.
+ sources := make(map[string]int)
+ for id, debugUnit := range s.unitDebugger.units {
+ if debugUnit.unit.finished.IsSet() {
+ delete(s.unitDebugger.units, id)
+ } else {
+ cnt := sources[debugUnit.unitSource]
+ sources[debugUnit.unitSource] = cnt + 1
+ }
+ }
+
+ // Print current state.
+ log.Debugf(
+ `scheduler: state: slotPace=%d avgPace=%d maxPace=%d maxLeveledPace=%d currentUnitID=%d clearanceUpTo=%d unitLife=%s slotDurations=%s/%s`,
+ s.slotPace.Load(),
+ s.GetAvgSlotPace(),
+ s.GetMaxSlotPace(),
+ s.GetMaxLeveledSlotPace(),
+ s.currentUnitID.Load(),
+ s.clearanceUpTo.Load(),
+ time.Duration(s.GetAvgUnitLife()).Round(10*time.Microsecond),
+ time.Duration(s.GetAvgWorkSlotDuration()).Round(10*time.Microsecond),
+ time.Duration(s.GetAvgCatchUpSlotDuration()).Round(10*time.Microsecond),
+ )
+ log.Debugf("scheduler: unit sources: %+v", sources)
+}
diff --git a/spn/unit/unit_test.go b/spn/unit/unit_test.go
new file mode 100644
index 00000000..8f5a5ac8
--- /dev/null
+++ b/spn/unit/unit_test.go
@@ -0,0 +1,104 @@
+package unit
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "math/rand"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUnit(t *testing.T) { //nolint:paralleltest
+ // Ignore deprectation, as the given alternative is not safe for concurrent use.
+ // The global rand methods use a locked seed, which is not available from outside.
+ rand.Seed(time.Now().UnixNano()) //nolint
+
+ size := 1000000
+ workers := 100
+
+ // Create and start scheduler.
+ s := NewScheduler(&SchedulerConfig{})
+ s.StartDebugLog()
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ err := s.SlotScheduler(ctx)
+ if err != nil {
+ panic(err)
+ }
+ }()
+ defer cancel()
+
+ // Create 10 workers.
+ var wg sync.WaitGroup
+ wg.Add(workers)
+ sizePerWorker := size / workers
+ for i := 0; i < workers; i++ {
+ go func() {
+ for i := 0; i < sizePerWorker; i++ {
+ u := s.NewUnit()
+
+ // Make 1% high priority.
+ if rand.Int()%100 == 0 { //nolint:gosec // This is a test.
+ u.MakeHighPriority()
+ }
+
+ u.WaitForSlot()
+ time.Sleep(10 * time.Microsecond)
+ u.Finish()
+ }
+ wg.Done()
+ }()
+ }
+
+ // Wait for workers to finish.
+ wg.Wait()
+
+ // Wait for two slot durations for values to update.
+ time.Sleep(s.config.SlotDuration * 2)
+
+ // Print current state.
+ s.cycleStats()
+ fmt.Printf(`scheduler state:
+ currentUnitID = %d
+ slotPace = %d
+ clearanceUpTo = %d
+ finished = %d
+ maxPace = %d
+ maxLeveledPace = %d
+ avgPace = %d
+ avgUnitLife = %s
+ avgWorkSlot = %s
+ avgCatchUpSlot = %s
+`,
+ s.currentUnitID.Load(),
+ s.slotPace.Load(),
+ s.clearanceUpTo.Load(),
+ s.finished.Load(),
+ s.GetMaxSlotPace(),
+ s.GetMaxLeveledSlotPace(),
+ s.GetAvgSlotPace(),
+ time.Duration(s.GetAvgUnitLife()),
+ time.Duration(s.GetAvgWorkSlotDuration()),
+ time.Duration(s.GetAvgCatchUpSlotDuration()),
+ )
+
+ // Check if everything seems good.
+ assert.Equal(t, size, int(s.currentUnitID.Load()), "currentUnitID must match size")
+ assert.GreaterOrEqual(
+ t,
+ int(s.clearanceUpTo.Load()),
+ size+int(float64(s.config.MinSlotPace)*s.config.SlotChangeRatePerStreak),
+ "clearanceUpTo must be at least size+minSlotPace",
+ )
+
+ // Shutdown
+ cancel()
+ time.Sleep(s.config.SlotDuration * 10)
+
+ // Check if scheduler shut down correctly.
+ assert.Equal(t, math.MaxInt64-math.MaxInt32, int(s.clearanceUpTo.Load()), "clearance must be near MaxInt64")
+}
From 66381baa1a6649b2588631ba42cc0eecdf07b3dd Mon Sep 17 00:00:00 2001
From: Patrick Pacher
Date: Tue, 19 Mar 2024 12:38:19 +0100
Subject: [PATCH 02/35] migrate build system to earthly: supports building
core, start and angular for all supported platforms. tauri still missing
---
.earthlyignore | 10 ++
Earthfile | 177 ++++++++++++++++++++++++++++++++
cmds/winkext-test/main.go | 13 +--
cmds/winkext-test/main_linux.go | 10 ++
desktop/angular/.gitignore | 4 +
5 files changed, 206 insertions(+), 8 deletions(-)
create mode 100644 .earthlyignore
create mode 100644 Earthfile
create mode 100644 cmds/winkext-test/main_linux.go
create mode 100644 desktop/angular/.gitignore
diff --git a/.earthlyignore b/.earthlyignore
new file mode 100644
index 00000000..9b694cb7
--- /dev/null
+++ b/.earthlyignore
@@ -0,0 +1,10 @@
+go.work
+go.work.sum
+
+dist/
+node_modules/
+
+desktop/angular/node_modules
+desktop/angular/dist
+desktop/angular/dist-lib
+desktop/angular/dist-extension
\ No newline at end of file
diff --git a/Earthfile b/Earthfile
new file mode 100644
index 00000000..1ca151dc
--- /dev/null
+++ b/Earthfile
@@ -0,0 +1,177 @@
+VERSION --arg-scope-and-set 0.8
+
+ARG --global go_version = 1.21
+ARG --global distro = alpine3.18
+ARG --global node_version = 18
+ARG --global outputDir = "./dist"
+
+go-deps:
+ FROM golang:${go_version}-${distro}
+ WORKDIR /go-workdir
+
+ # These cache dirs will be used in later test and build targets
+ # to persist cached go packages.
+ #
+ # NOTE: cache only gets persisted on successful builds. A test
+ # failure will prevent the go cache from being persisted.
+ ENV GOCACHE = "/.go-cache"
+ ENV GOMODCACHE = "/.go-mod-cache"
+
+ # Copying only go.mod and go.sum means that the cache for this
+ # target will only be busted when go.mod/go.sum change. This
+ # means that we can cache the results of 'go mod download'.
+ COPY go.mod .
+ COPY go.sum .
+ RUN go mod download
+
+
+go-base:
+ FROM +go-deps
+
+ # Only copy go-code related files to improve caching.
+ # (i.e. do not rebuild go if only the angular app changed)
+ COPY cmds ./cmds
+ COPY runtime ./runtime
+ COPY service ./service
+ COPY spn ./spn
+
+# mod-tidy runs 'go mod tidy', saving go.mod and go.sum locally.
+mod-tidy:
+ FROM +go-base
+
+ RUN go mod tidy
+ SAVE ARTIFACT go.mod AS LOCAL go.mod
+ SAVE ARTIFACT --if-exists go.sum AS LOCAL go.sum
+
+# build-go runs 'go build ./cmds/...', saving artifacts locally.
+# If --CMDS is not set, it defaults to building portmaster-start, portmaster-core and hub
+build-go:
+ FROM +go-base
+
+ # Arguments for cross-compilation.
+ ARG GOOS=linux
+ ARG GOARCH=amd64
+ ARG GOARM
+ ARG CMDS=portmaster-start portmaster-core hub
+
+ CACHE --sharing shared "$GOCACHE"
+ CACHE --sharing shared "$GOMODCACHE"
+
+ RUN mkdir /tmp/build
+ ENV CGO_ENABLED = "0"
+
+ IF [ "${CMDS}" = "" ]
+ LET CMDS=$(ls -1 "./cmds/")
+ END
+
+ # Build all go binaries from the specified in CMDS
+ FOR bin IN $CMDS
+ RUN go build -o "/tmp/build/" ./cmds/${bin}
+ END
+
+ LET NAME = ""
+
+ FOR bin IN $(ls -1 "/tmp/build/")
+ SET NAME = "${outputDir}/${GOOS}_${GOARCH}/${bin}"
+ IF [ "${GOARM}" != "" ]
+ SET NAME = "${outputDir}/${GOOS}_${GOARCH}v${GOARM}/${bin}"
+ END
+
+ SAVE ARTIFACT "/tmp/build/${bin}" AS LOCAL "${NAME}"
+ END
+
+# Test one or more go packages.
+# Run `earthly +test-go` to test all packages
+# Run `earthly +test-go --PKG="service/firewall"` to only test a specific package.
+# Run `earthly +test-go --TESTFLAGS="-short"` to add custom flags to go test (-short in this case)
+test-go:
+ FROM +go-base
+
+ ARG GOOS=linux
+ ARG GOARCH=amd64
+ ARG GOARM
+ ARG TESTFLAGS
+ ARG PKG="..."
+
+ CACHE --sharing shared "$GOCACHE"
+ CACHE --sharing shared "$GOMODCACHE"
+
+ FOR pkg IN $(go list -e "./${PKG}")
+ RUN go test -cover ${TESTFLAGS} ${pkg}
+ END
+
+test-go-all-platforms:
+ # Linux platforms:
+ BUILD +test-go --GOARCH=amd64 --GOOS=linux
+ BUILD +test-go --GOARCH=arm64 --GOOS=linux
+ BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=5
+ BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=6
+ BUILD +test-go --GOARCH=arm --GOOS=linux --GOARM=7
+
+ # Windows platforms:
+ BUILD +test-go --GOARCH=amd64 --GOOS=windows
+ BUILD +test-go --GOARCH=arm64 --GOOS=windows
+
+# Builds portmaster-start and portmaster-core for all supported platforms
+build-go-release:
+ # Linux platforms:
+ BUILD +build-go --GOARCH=amd64 --GOOS=linux
+ BUILD +build-go --GOARCH=arm64 --GOOS=linux
+ BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=5
+ BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=6
+ BUILD +build-go --GOARCH=arm --GOOS=linux --GOARM=7
+
+ # Windows platforms:
+ BUILD +build-go --GOARCH=amd64 --GOOS=windows
+ BUILD +build-go --GOARCH=arm64 --GOOS=windows
+
+# Builds all binaries from the cmds/ folder for linux/windows AMD64
+# Most utility binaries are never needed on other platforms.
+build-utils:
+ BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=linux
+ BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=windows
+
+# Prepares the angular project
+angular-deps:
+ FROM node:${node_version}
+ WORKDIR /app/ui
+
+ RUN apt update && apt install zip
+
+ CACHE --sharing shared "/app/ui/node_modules"
+
+ COPY desktop/angular/package.json .
+ COPY desktop/angular/package-lock.json .
+ RUN npm install
+
+
+angular-base:
+ FROM +angular-deps
+
+ COPY desktop/angular/ .
+
+# Build the Portmaster UI (angular) in release mode
+angular-release:
+ FROM +angular-base
+
+ CACHE --sharing shared "/app/ui/node_modules"
+
+ RUN npm run build
+ RUN zip -r ./angular.zip ./dist
+ SAVE ARTIFACT "./angular.zip" AS LOCAL ${outputDir}/angular.zip
+ SAVE ARTIFACT "./dist" AS LOCAL ${outputDir}/angular
+
+
+# Build the Portmaster UI (angular) in dev mode
+angular-dev:
+ FROM +angular-base
+
+ CACHE --sharing shared "/app/ui/node_modules"
+
+ RUN npm run build:dev
+ SAVE ARTIFACT ./dist AS LOCAL ${outputDir}/angular
+
+
+release:
+ BUILD +build-go-release
+ BUILD +angular-release
diff --git a/cmds/winkext-test/main.go b/cmds/winkext-test/main.go
index 0a3d8c4b..9b17b1a3 100644
--- a/cmds/winkext-test/main.go
+++ b/cmds/winkext-test/main.go
@@ -4,6 +4,7 @@
package main
import (
+ "context"
"flag"
"fmt"
"os"
@@ -75,7 +76,7 @@ func main() {
log.Infof("using .sys at %s", sysPath)
// init
- err = windowskext.Init(dllPath, sysPath)
+ err = windowskext.Init(sysPath)
if err != nil {
log.Criticalf("failed to init kext: %s", err)
return
@@ -89,7 +90,7 @@ func main() {
}
packets = make(chan packet.Packet, 1000)
- go windowskext.Handler(packets)
+ go windowskext.Handler(context.TODO(), packets)
go handlePackets()
// catch interrupt for clean shutdown
@@ -135,12 +136,8 @@ func handlePackets() {
handledPackets++
if getPayload {
- data, err := pkt.GetPayload()
- if err != nil {
- log.Errorf("failed to get payload: %s", err)
- } else {
- log.Infof("payload is: %x", data)
- }
+ data := pkt.Payload()
+ log.Infof("payload is: %x", data)
}
// reroute dns requests to nameserver
diff --git a/cmds/winkext-test/main_linux.go b/cmds/winkext-test/main_linux.go
new file mode 100644
index 00000000..951e30d2
--- /dev/null
+++ b/cmds/winkext-test/main_linux.go
@@ -0,0 +1,10 @@
+//go:build linux
+// +build linux
+
+package main
+
+import "log"
+
+func main() {
+ log.Fatalf("winkext-test not supported on linux")
+}
diff --git a/desktop/angular/.gitignore b/desktop/angular/.gitignore
new file mode 100644
index 00000000..28f76669
--- /dev/null
+++ b/desktop/angular/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+dist-extension
+dist-lib
\ No newline at end of file
From 4b77945517718f1b3591de96e9ae1d39260639ff Mon Sep 17 00:00:00 2001
From: Patrick Pacher
Date: Wed, 20 Mar 2024 10:43:29 +0100
Subject: [PATCH 03/35] Migrate Angular UI from portmaster-ui to
desktop/angular. Update Earthfile to build libs, UI and tauri-builtin
---
.earthlyignore | 7 +-
Earthfile | 63 +-
assets/fonts/Roboto-300/LICENSE.txt | 202 +
assets/fonts/Roboto-300/Roboto-300.eot | Bin 0 -> 16205 bytes
assets/fonts/Roboto-300/Roboto-300.svg | 314 +
assets/fonts/Roboto-300/Roboto-300.ttf | Bin 0 -> 32664 bytes
assets/fonts/Roboto-300/Roboto-300.woff | Bin 0 -> 13360 bytes
assets/fonts/Roboto-300/Roboto-300.woff2 | Bin 0 -> 10324 bytes
assets/fonts/Roboto-300italic/LICENSE.txt | 202 +
.../Roboto-300italic/Roboto-300italic.eot | Bin 0 -> 17886 bytes
.../Roboto-300italic/Roboto-300italic.svg | 327 +
.../Roboto-300italic/Roboto-300italic.ttf | Bin 0 -> 34384 bytes
.../Roboto-300italic/Roboto-300italic.woff | Bin 0 -> 15004 bytes
.../Roboto-300italic/Roboto-300italic.woff2 | Bin 0 -> 11844 bytes
assets/fonts/Roboto-500/LICENSE.txt | 202 +
assets/fonts/Roboto-500/Roboto-500.eot | Bin 0 -> 16310 bytes
assets/fonts/Roboto-500/Roboto-500.svg | 305 +
assets/fonts/Roboto-500/Roboto-500.ttf | Bin 0 -> 32580 bytes
assets/fonts/Roboto-500/Roboto-500.woff | Bin 0 -> 13248 bytes
assets/fonts/Roboto-500/Roboto-500.woff2 | Bin 0 -> 10248 bytes
assets/fonts/Roboto-500italic/LICENSE.txt | 202 +
.../Roboto-500italic/Roboto-500italic.eot | Bin 0 -> 17584 bytes
.../Roboto-500italic/Roboto-500italic.svg | 327 +
.../Roboto-500italic/Roboto-500italic.ttf | Bin 0 -> 33868 bytes
.../Roboto-500italic/Roboto-500italic.woff | Bin 0 -> 14620 bytes
.../Roboto-500italic/Roboto-500italic.woff2 | Bin 0 -> 11532 bytes
assets/fonts/Roboto-700/LICENSE.txt | 202 +
assets/fonts/Roboto-700/Roboto-700.eot | Bin 0 -> 16208 bytes
assets/fonts/Roboto-700/Roboto-700.svg | 310 +
assets/fonts/Roboto-700/Roboto-700.ttf | Bin 0 -> 32500 bytes
assets/fonts/Roboto-700/Roboto-700.woff | Bin 0 -> 13348 bytes
assets/fonts/Roboto-700/Roboto-700.woff2 | Bin 0 -> 10276 bytes
assets/fonts/Roboto-700italic/LICENSE.txt | 202 +
.../Roboto-700italic/Roboto-700italic.eot | Bin 0 -> 17151 bytes
.../Roboto-700italic/Roboto-700italic.svg | 325 +
.../Roboto-700italic/Roboto-700italic.ttf | Bin 0 -> 32808 bytes
.../Roboto-700italic/Roboto-700italic.woff | Bin 0 -> 14708 bytes
.../Roboto-700italic/Roboto-700italic.woff2 | Bin 0 -> 11492 bytes
assets/fonts/Roboto-italic/LICENSE.txt | 202 +
assets/fonts/Roboto-italic/Roboto-italic.eot | Bin 0 -> 17534 bytes
assets/fonts/Roboto-italic/Roboto-italic.svg | 323 +
assets/fonts/Roboto-italic/Roboto-italic.ttf | Bin 0 -> 33404 bytes
assets/fonts/Roboto-italic/Roboto-italic.woff | Bin 0 -> 14716 bytes
.../fonts/Roboto-italic/Roboto-italic.woff2 | Bin 0 -> 11500 bytes
assets/fonts/Roboto-regular/LICENSE.txt | 202 +
.../fonts/Roboto-regular/Roboto-regular.eot | Bin 0 -> 16227 bytes
.../fonts/Roboto-regular/Roboto-regular.svg | 308 +
.../fonts/Roboto-regular/Roboto-regular.ttf | Bin 0 -> 32652 bytes
.../fonts/Roboto-regular/Roboto-regular.woff | Bin 0 -> 13308 bytes
.../fonts/Roboto-regular/Roboto-regular.woff2 | Bin 0 -> 10292 bytes
assets/fonts/roboto-slimfix.css | 111 +
assets/fonts/roboto.css | 111 +
assets/icons/README.md | 3 +
assets/icons/pm_dark_128.png | Bin 0 -> 10900 bytes
assets/icons/pm_dark_256.png | Bin 0 -> 16639 bytes
assets/icons/pm_dark_512.ico | Bin 0 -> 110291 bytes
assets/icons/pm_dark_512.png | Bin 0 -> 31400 bytes
assets/icons/pm_dark_blue_128.png | Bin 0 -> 10678 bytes
assets/icons/pm_dark_blue_256.png | Bin 0 -> 17006 bytes
assets/icons/pm_dark_blue_512.ico | Bin 0 -> 99678 bytes
assets/icons/pm_dark_blue_512.png | Bin 0 -> 26121 bytes
assets/icons/pm_dark_green_128.png | Bin 0 -> 11587 bytes
assets/icons/pm_dark_green_256.png | Bin 0 -> 18162 bytes
assets/icons/pm_dark_green_512.ico | Bin 0 -> 112077 bytes
assets/icons/pm_dark_green_512.png | Bin 0 -> 28931 bytes
assets/icons/pm_dark_red_128.png | Bin 0 -> 11443 bytes
assets/icons/pm_dark_red_256.png | Bin 0 -> 17932 bytes
assets/icons/pm_dark_red_512.ico | Bin 0 -> 112150 bytes
assets/icons/pm_dark_red_512.png | Bin 0 -> 29114 bytes
assets/icons/pm_dark_yellow_128.png | Bin 0 -> 11569 bytes
assets/icons/pm_dark_yellow_256.png | Bin 0 -> 18137 bytes
assets/icons/pm_dark_yellow_512.ico | Bin 0 -> 112046 bytes
assets/icons/pm_dark_yellow_512.png | Bin 0 -> 28968 bytes
assets/icons/pm_light_128.png | Bin 0 -> 11328 bytes
assets/icons/pm_light_256.png | Bin 0 -> 17563 bytes
assets/icons/pm_light_512.ico | Bin 0 -> 111080 bytes
assets/icons/pm_light_512.png | Bin 0 -> 31361 bytes
assets/icons/pm_light_blue_128.png | Bin 0 -> 10888 bytes
assets/icons/pm_light_blue_256.png | Bin 0 -> 15813 bytes
assets/icons/pm_light_blue_512.ico | Bin 0 -> 113172 bytes
assets/icons/pm_light_blue_512.png | Bin 0 -> 25466 bytes
assets/icons/pm_light_green_128.png | Bin 0 -> 11555 bytes
assets/icons/pm_light_green_256.png | Bin 0 -> 18828 bytes
assets/icons/pm_light_green_512.ico | Bin 0 -> 113175 bytes
assets/icons/pm_light_green_512.png | Bin 0 -> 28929 bytes
assets/icons/pm_light_red_128.png | Bin 0 -> 11625 bytes
assets/icons/pm_light_red_256.png | Bin 0 -> 18969 bytes
assets/icons/pm_light_red_512.ico | Bin 0 -> 113172 bytes
assets/icons/pm_light_red_512.png | Bin 0 -> 28952 bytes
assets/icons/pm_light_yellow_128.png | Bin 0 -> 11555 bytes
assets/icons/pm_light_yellow_256.png | Bin 0 -> 18756 bytes
assets/icons/pm_light_yellow_512.ico | Bin 0 -> 113171 bytes
assets/icons/pm_light_yellow_512.png | Bin 0 -> 29002 bytes
assets/img/Mobile.svg | 1 +
assets/img/flags/AD.png | Bin 0 -> 263 bytes
assets/img/flags/AE.png | Bin 0 -> 107 bytes
assets/img/flags/AF.png | Bin 0 -> 259 bytes
assets/img/flags/AG.png | Bin 0 -> 302 bytes
assets/img/flags/AI.png | Bin 0 -> 332 bytes
assets/img/flags/AL.png | Bin 0 -> 291 bytes
assets/img/flags/AM.png | Bin 0 -> 105 bytes
assets/img/flags/AN.png | Bin 0 -> 145 bytes
assets/img/flags/AO.png | Bin 0 -> 241 bytes
assets/img/flags/AQ.png | Bin 0 -> 382 bytes
assets/img/flags/AR.png | Bin 0 -> 209 bytes
assets/img/flags/AS.png | Bin 0 -> 448 bytes
assets/img/flags/AT.png | Bin 0 -> 98 bytes
assets/img/flags/AU.png | Bin 0 -> 228 bytes
assets/img/flags/AW.png | Bin 0 -> 182 bytes
assets/img/flags/AX.png | Bin 0 -> 121 bytes
assets/img/flags/AZ.png | Bin 0 -> 267 bytes
assets/img/flags/BA.png | Bin 0 -> 355 bytes
assets/img/flags/BB.png | Bin 0 -> 159 bytes
assets/img/flags/BD.png | Bin 0 -> 211 bytes
assets/img/flags/BE.png | Bin 0 -> 102 bytes
assets/img/flags/BF.png | Bin 0 -> 166 bytes
assets/img/flags/BG.png | Bin 0 -> 103 bytes
assets/img/flags/BH.png | Bin 0 -> 129 bytes
assets/img/flags/BI.png | Bin 0 -> 454 bytes
assets/img/flags/BJ.png | Bin 0 -> 106 bytes
assets/img/flags/BL.png | Bin 0 -> 539 bytes
assets/img/flags/BM.png | Bin 0 -> 321 bytes
assets/img/flags/BN.png | Bin 0 -> 518 bytes
assets/img/flags/BO.png | Bin 0 -> 236 bytes
assets/img/flags/BR.png | Bin 0 -> 432 bytes
assets/img/flags/BS.png | Bin 0 -> 171 bytes
assets/img/flags/BT.png | Bin 0 -> 449 bytes
assets/img/flags/BW.png | Bin 0 -> 108 bytes
assets/img/flags/BY.png | Bin 0 -> 151 bytes
assets/img/flags/BZ.png | Bin 0 -> 337 bytes
assets/img/flags/CA.png | Bin 0 -> 177 bytes
assets/img/flags/CC.png | Bin 0 -> 259 bytes
assets/img/flags/CD.png | Bin 0 -> 432 bytes
assets/img/flags/CF.png | Bin 0 -> 162 bytes
assets/img/flags/CG.png | Bin 0 -> 152 bytes
assets/img/flags/CH.png | Bin 0 -> 100 bytes
assets/img/flags/CI.png | Bin 0 -> 100 bytes
assets/img/flags/CK.png | Bin 0 -> 331 bytes
assets/img/flags/CL.png | Bin 0 -> 150 bytes
assets/img/flags/CM.png | Bin 0 -> 163 bytes
assets/img/flags/CN.png | Bin 0 -> 310 bytes
assets/img/flags/CO.png | Bin 0 -> 108 bytes
assets/img/flags/CR.png | Bin 0 -> 110 bytes
assets/img/flags/CT.png | Bin 0 -> 1356 bytes
assets/img/flags/CU.png | Bin 0 -> 215 bytes
assets/img/flags/CV.png | Bin 0 -> 138 bytes
assets/img/flags/CW.png | Bin 0 -> 191 bytes
assets/img/flags/CX.png | Bin 0 -> 390 bytes
assets/img/flags/CY.png | Bin 0 -> 364 bytes
assets/img/flags/CZ.png | Bin 0 -> 221 bytes
assets/img/flags/DE.png | Bin 0 -> 102 bytes
assets/img/flags/DJ.png | Bin 0 -> 228 bytes
assets/img/flags/DK.png | Bin 0 -> 106 bytes
assets/img/flags/DM.png | Bin 0 -> 333 bytes
assets/img/flags/DO.png | Bin 0 -> 142 bytes
assets/img/flags/DZ.png | Bin 0 -> 309 bytes
assets/img/flags/EC.png | Bin 0 -> 264 bytes
assets/img/flags/EE.png | Bin 0 -> 102 bytes
assets/img/flags/EG.png | Bin 0 -> 199 bytes
assets/img/flags/EH.png | Bin 0 -> 248 bytes
assets/img/flags/ER.png | Bin 0 -> 421 bytes
assets/img/flags/ES.png | Bin 0 -> 221 bytes
assets/img/flags/ET.png | Bin 0 -> 420 bytes
assets/img/flags/EU.png | Bin 0 -> 316 bytes
assets/img/flags/FI.png | Bin 0 -> 103 bytes
assets/img/flags/FJ.png | Bin 0 -> 387 bytes
assets/img/flags/FK.png | Bin 0 -> 344 bytes
assets/img/flags/FM.png | Bin 0 -> 198 bytes
assets/img/flags/FO.png | Bin 0 -> 122 bytes
assets/img/flags/FR.png | Bin 0 -> 100 bytes
assets/img/flags/GA.png | Bin 0 -> 108 bytes
assets/img/flags/GB.png | Bin 0 -> 353 bytes
assets/img/flags/GD.png | Bin 0 -> 313 bytes
assets/img/flags/GE.png | Bin 0 -> 122 bytes
assets/img/flags/GG.png | Bin 0 -> 124 bytes
assets/img/flags/GH.png | Bin 0 -> 162 bytes
assets/img/flags/GI.png | Bin 0 -> 245 bytes
assets/img/flags/GL.png | Bin 0 -> 196 bytes
assets/img/flags/GM.png | Bin 0 -> 115 bytes
assets/img/flags/GN.png | Bin 0 -> 103 bytes
assets/img/flags/GQ.png | Bin 0 -> 308 bytes
assets/img/flags/GR.png | Bin 0 -> 141 bytes
assets/img/flags/GS.png | Bin 0 -> 455 bytes
assets/img/flags/GT.png | Bin 0 -> 198 bytes
assets/img/flags/GU.png | Bin 0 -> 228 bytes
assets/img/flags/GW.png | Bin 0 -> 149 bytes
assets/img/flags/GY.png | Bin 0 -> 393 bytes
assets/img/flags/HK.png | Bin 0 -> 418 bytes
assets/img/flags/HN.png | Bin 0 -> 154 bytes
assets/img/flags/HR.png | Bin 0 -> 391 bytes
assets/img/flags/HT.png | Bin 0 -> 206 bytes
assets/img/flags/HU.png | Bin 0 -> 104 bytes
assets/img/flags/IC.png | Bin 0 -> 183 bytes
assets/img/flags/ID.png | Bin 0 -> 98 bytes
assets/img/flags/IE.png | Bin 0 -> 99 bytes
assets/img/flags/IL.png | Bin 0 -> 180 bytes
assets/img/flags/IM.png | Bin 0 -> 367 bytes
assets/img/flags/IN.png | Bin 0 -> 194 bytes
assets/img/flags/IQ.png | Bin 0 -> 269 bytes
assets/img/flags/IR.png | Bin 0 -> 356 bytes
assets/img/flags/IS.png | Bin 0 -> 124 bytes
assets/img/flags/IT.png | Bin 0 -> 100 bytes
assets/img/flags/JE.png | Bin 0 -> 403 bytes
assets/img/flags/JM.png | Bin 0 -> 392 bytes
assets/img/flags/JO.png | Bin 0 -> 236 bytes
assets/img/flags/JP.png | Bin 0 -> 155 bytes
assets/img/flags/KE.png | Bin 0 -> 324 bytes
assets/img/flags/KG.png | Bin 0 -> 380 bytes
assets/img/flags/KH.png | Bin 0 -> 232 bytes
assets/img/flags/KI.png | Bin 0 -> 517 bytes
assets/img/flags/KM.png | Bin 0 -> 272 bytes
assets/img/flags/KN.png | Bin 0 -> 403 bytes
assets/img/flags/KP.png | Bin 0 -> 197 bytes
assets/img/flags/KR.png | Bin 0 -> 413 bytes
assets/img/flags/KW.png | Bin 0 -> 185 bytes
assets/img/flags/KY.png | Bin 0 -> 338 bytes
assets/img/flags/KZ.png | Bin 0 -> 405 bytes
assets/img/flags/LA.png | Bin 0 -> 175 bytes
assets/img/flags/LB.png | Bin 0 -> 213 bytes
assets/img/flags/LC.png | Bin 0 -> 197 bytes
assets/img/flags/LI.png | Bin 0 -> 216 bytes
assets/img/flags/LICENSE.txt | 7 +
assets/img/flags/LK.png | Bin 0 -> 325 bytes
assets/img/flags/LR.png | Bin 0 -> 142 bytes
assets/img/flags/LS.png | Bin 0 -> 200 bytes
assets/img/flags/LT.png | Bin 0 -> 108 bytes
assets/img/flags/LU.png | Bin 0 -> 105 bytes
assets/img/flags/LV.png | Bin 0 -> 99 bytes
assets/img/flags/LY.png | Bin 0 -> 212 bytes
assets/img/flags/MA.png | Bin 0 -> 302 bytes
assets/img/flags/MC.png | Bin 0 -> 98 bytes
assets/img/flags/MD.png | Bin 0 -> 190 bytes
assets/img/flags/ME.png | Bin 0 -> 323 bytes
assets/img/flags/MF.png | Bin 0 -> 161 bytes
assets/img/flags/MG.png | Bin 0 -> 101 bytes
assets/img/flags/MH.png | Bin 0 -> 382 bytes
assets/img/flags/MK.png | Bin 0 -> 378 bytes
assets/img/flags/ML.png | Bin 0 -> 103 bytes
assets/img/flags/MM.png | Bin 0 -> 195 bytes
assets/img/flags/MN.png | Bin 0 -> 225 bytes
assets/img/flags/MO.png | Bin 0 -> 413 bytes
assets/img/flags/MP.png | Bin 0 -> 548 bytes
assets/img/flags/MQ.png | Bin 0 -> 202 bytes
assets/img/flags/MR.png | Bin 0 -> 250 bytes
assets/img/flags/MS.png | Bin 0 -> 346 bytes
assets/img/flags/MT.png | Bin 0 -> 114 bytes
assets/img/flags/MU.png | Bin 0 -> 116 bytes
assets/img/flags/MV.png | Bin 0 -> 201 bytes
assets/img/flags/MW.png | Bin 0 -> 193 bytes
assets/img/flags/MX.png | Bin 0 -> 207 bytes
assets/img/flags/MY.png | Bin 0 -> 236 bytes
assets/img/flags/MZ.png | Bin 0 -> 315 bytes
assets/img/flags/NA.png | Bin 0 -> 452 bytes
assets/img/flags/NC.png | Bin 0 -> 325 bytes
assets/img/flags/NE.png | Bin 0 -> 153 bytes
assets/img/flags/NF.png | Bin 0 -> 295 bytes
assets/img/flags/NG.png | Bin 0 -> 98 bytes
assets/img/flags/NI.png | Bin 0 -> 210 bytes
assets/img/flags/NL.png | Bin 0 -> 104 bytes
assets/img/flags/NO.png | Bin 0 -> 124 bytes
assets/img/flags/NP.png | Bin 0 -> 241 bytes
assets/img/flags/NR.png | Bin 0 -> 172 bytes
assets/img/flags/NU.png | Bin 0 -> 252 bytes
assets/img/flags/NZ.png | Bin 0 -> 200 bytes
assets/img/flags/OM.png | Bin 0 -> 198 bytes
assets/img/flags/PA.png | Bin 0 -> 174 bytes
assets/img/flags/PE.png | Bin 0 -> 98 bytes
assets/img/flags/PF.png | Bin 0 -> 217 bytes
assets/img/flags/PG.png | Bin 0 -> 444 bytes
assets/img/flags/PH.png | Bin 0 -> 342 bytes
assets/img/flags/PK.png | Bin 0 -> 306 bytes
assets/img/flags/PL.png | Bin 0 -> 102 bytes
assets/img/flags/PN.png | Bin 0 -> 423 bytes
assets/img/flags/PR.png | Bin 0 -> 216 bytes
assets/img/flags/PS.png | Bin 0 -> 157 bytes
assets/img/flags/PT.png | Bin 0 -> 303 bytes
assets/img/flags/PW.png | Bin 0 -> 209 bytes
assets/img/flags/PY.png | Bin 0 -> 197 bytes
assets/img/flags/QA.png | Bin 0 -> 190 bytes
assets/img/flags/RE.png | Bin 0 -> 443 bytes
assets/img/flags/RO.png | Bin 0 -> 103 bytes
assets/img/flags/RS.png | Bin 0 -> 331 bytes
assets/img/flags/RU.png | Bin 0 -> 98 bytes
assets/img/flags/RW.png | Bin 0 -> 182 bytes
assets/img/flags/SA.png | Bin 0 -> 426 bytes
assets/img/flags/SB.png | Bin 0 -> 306 bytes
assets/img/flags/SC.png | Bin 0 -> 314 bytes
assets/img/flags/SD.png | Bin 0 -> 156 bytes
assets/img/flags/SE.png | Bin 0 -> 109 bytes
assets/img/flags/SG.png | Bin 0 -> 253 bytes
assets/img/flags/SH.png | Bin 0 -> 333 bytes
assets/img/flags/SI.png | Bin 0 -> 177 bytes
assets/img/flags/SK.png | Bin 0 -> 225 bytes
assets/img/flags/SL.png | Bin 0 -> 104 bytes
assets/img/flags/SM.png | Bin 0 -> 291 bytes
assets/img/flags/SN.png | Bin 0 -> 160 bytes
assets/img/flags/SO.png | Bin 0 -> 192 bytes
assets/img/flags/SR.png | Bin 0 -> 166 bytes
assets/img/flags/SS.png | Bin 0 -> 289 bytes
assets/img/flags/ST.png | Bin 0 -> 243 bytes
assets/img/flags/SV.png | Bin 0 -> 209 bytes
assets/img/flags/SX.png | Bin 0 -> 483 bytes
assets/img/flags/SY.png | Bin 0 -> 161 bytes
assets/img/flags/SZ.png | Bin 0 -> 366 bytes
assets/img/flags/TC.png | Bin 0 -> 312 bytes
assets/img/flags/TD.png | Bin 0 -> 103 bytes
assets/img/flags/TF.png | Bin 0 -> 224 bytes
assets/img/flags/TG.png | Bin 0 -> 174 bytes
assets/img/flags/TH.png | Bin 0 -> 110 bytes
assets/img/flags/TJ.png | Bin 0 -> 203 bytes
assets/img/flags/TK.png | Bin 0 -> 260 bytes
assets/img/flags/TL.png | Bin 0 -> 277 bytes
assets/img/flags/TM.png | Bin 0 -> 392 bytes
assets/img/flags/TN.png | Bin 0 -> 271 bytes
assets/img/flags/TO.png | Bin 0 -> 114 bytes
assets/img/flags/TR.png | Bin 0 -> 311 bytes
assets/img/flags/TT.png | Bin 0 -> 358 bytes
assets/img/flags/TV.png | Bin 0 -> 398 bytes
assets/img/flags/TW.png | Bin 0 -> 205 bytes
assets/img/flags/TZ.png | Bin 0 -> 415 bytes
assets/img/flags/UA.png | Bin 0 -> 102 bytes
assets/img/flags/UG.png | Bin 0 -> 188 bytes
assets/img/flags/US.png | Bin 0 -> 120 bytes
assets/img/flags/UY.png | Bin 0 -> 216 bytes
assets/img/flags/UZ.png | Bin 0 -> 163 bytes
assets/img/flags/VA.png | Bin 0 -> 202 bytes
assets/img/flags/VC.png | Bin 0 -> 217 bytes
assets/img/flags/VE.png | Bin 0 -> 302 bytes
assets/img/flags/VG.png | Bin 0 -> 337 bytes
assets/img/flags/VI.png | Bin 0 -> 500 bytes
assets/img/flags/VN.png | Bin 0 -> 193 bytes
assets/img/flags/VU.png | Bin 0 -> 302 bytes
assets/img/flags/WF.png | Bin 0 -> 182 bytes
assets/img/flags/WS.png | Bin 0 -> 236 bytes
assets/img/flags/YE.png | Bin 0 -> 103 bytes
assets/img/flags/YT.png | Bin 0 -> 482 bytes
assets/img/flags/ZA.png | Bin 0 -> 348 bytes
assets/img/flags/ZM.png | Bin 0 -> 189 bytes
assets/img/flags/ZW.png | Bin 0 -> 300 bytes
assets/img/flags/_abkhazia.png | Bin 0 -> 276 bytes
assets/img/flags/_basque-country.png | Bin 0 -> 240 bytes
.../flags/_british-antarctic-territory.png | Bin 0 -> 361 bytes
assets/img/flags/_commonwealth.png | Bin 0 -> 443 bytes
assets/img/flags/_england.png | Bin 0 -> 102 bytes
assets/img/flags/_gosquared.png | Bin 0 -> 239 bytes
assets/img/flags/_kosovo.png | Bin 0 -> 434 bytes
assets/img/flags/_mars.png | Bin 0 -> 103 bytes
assets/img/flags/_nagorno-karabakh.png | Bin 0 -> 141 bytes
assets/img/flags/_nato.png | Bin 0 -> 143 bytes
assets/img/flags/_northern-cyprus.png | Bin 0 -> 220 bytes
assets/img/flags/_olympics.png | Bin 0 -> 329 bytes
assets/img/flags/_red-cross.png | Bin 0 -> 109 bytes
assets/img/flags/_scotland.png | Bin 0 -> 351 bytes
assets/img/flags/_somaliland.png | Bin 0 -> 315 bytes
assets/img/flags/_south-ossetia.png | Bin 0 -> 100 bytes
assets/img/flags/_united-nations.png | Bin 0 -> 366 bytes
assets/img/flags/_unknown.png | Bin 0 -> 176 bytes
assets/img/flags/_wales.png | Bin 0 -> 527 bytes
assets/img/linux.svg | 1 +
assets/img/mac.svg | 1 +
assets/img/plants1-br.png | Bin 0 -> 25340 bytes
assets/img/plants1.png | Bin 0 -> 36805 bytes
.../access-regional-content-easily.png | Bin 0 -> 80815 bytes
.../built-from-the-ground-up.png | Bin 0 -> 281930 bytes
.../img/spn-feature-carousel/bye-bye-vpns.png | Bin 0 -> 103251 bytes
.../easily-control-your-privacy.png | Bin 0 -> 72739 bytes
.../multiple-identities-for-each-app.png | Bin 0 -> 49252 bytes
assets/img/spn-login.png | Bin 0 -> 89031 bytes
assets/img/windows.svg | 1 +
assets/world-50m.json | 1 +
desktop/angular/.gitignore | 3 +-
desktop/angular/README.md | 104 +
desktop/angular/angular.json | 457 +
desktop/angular/assets | 1 +
.../angular/browser-extension-dev.config.ts | 16 +
desktop/angular/browser-extension.config.ts | 5 +
desktop/angular/docker.sh | 18 +
desktop/angular/e2e/protractor.conf.js | 36 +
desktop/angular/e2e/src/app.e2e-spec.ts | 23 +
desktop/angular/e2e/src/app.po.ts | 11 +
desktop/angular/e2e/tsconfig.json | 14 +
desktop/angular/karma.conf.js | 32 +
desktop/angular/package-lock.json | 34959 ++++++++++++++++
desktop/angular/package.json | 105 +
.../portmaster-chrome-extension/karma.conf.js | 44 +
.../src/app/app-routing.module.ts | 15 +
.../src/app/app.component.html | 3 +
.../src/app/app.component.scss | 3 +
.../src/app/app.component.ts | 54 +
.../src/app/app.module.ts | 39 +
.../domain-list/domain-list.component.html | 27 +
.../app/domain-list/domain-list.component.ts | 129 +
.../src/app/domain-list/index.ts | 1 +
.../src/app/header/header.component.html | 22 +
.../src/app/header/header.component.scss | 29 +
.../src/app/header/header.component.ts | 9 +
.../src/app/header/index.ts | 1 +
.../src/app/interceptor.ts | 45 +
.../src/app/request-interceptor.service.ts | 49 +
.../src/app/welcome/index.ts | 2 +
.../src/app/welcome/intro.component.html | 48 +
.../src/app/welcome/intro.component.ts | 44 +
.../src/app/welcome/welcome.module.ts | 19 +
.../src/assets}/.gitkeep | 0
.../src/assets/icon_128.png | Bin 0 -> 11328 bytes
.../src/background.ts | 133 +
.../src/background/commands.ts | 14 +
.../src/background/tab-tracker.ts | 126 +
.../src/background/tab-utils.ts | 9 +
.../src/environments/environment.prod.ts | 3 +
.../src/environments/environment.ts | 16 +
.../src/favicon.ico | Bin 0 -> 948 bytes
.../src/index.html | 13 +
.../portmaster-chrome-extension/src/main.ts | 12 +
.../src/manifest.json | 23 +
.../src/polyfills.ts | 53 +
.../src/styles.scss | 8 +
.../portmaster-chrome-extension/src/test.ts | 14 +
.../tsconfig.app.json | 18 +
.../tsconfig.spec.json | 18 +
.../projects/safing/portmaster-api/README.md | 24 +
.../safing/portmaster-api/karma.conf.js | 44 +
.../safing/portmaster-api/ng-package.json | 7 +
.../safing/portmaster-api/package-lock.json | 132 +
.../safing/portmaster-api/package.json | 14 +
.../src/lib/app-profile.service.ts | 262 +
.../src/lib/app-profile.types.ts | 215 +
.../portmaster-api/src/lib/config.service.ts | 128 +
.../portmaster-api/src/lib/config.types.ts | 348 +
.../portmaster-api/src/lib/core.types.ts | 34 +
.../src/lib/debug-api.service.ts | 54 +
.../safing/portmaster-api/src/lib/features.ts | 8 +
.../src/lib/meta-api.service.ts | 106 +
.../safing/portmaster-api/src/lib/module.ts | 55 +
.../src/lib/netquery.service.ts | 543 +
.../portmaster-api/src/lib/network.types.ts | 314 +
.../portmaster-api/src/lib/portapi.service.ts | 1011 +
.../portmaster-api/src/lib/portapi.types.ts | 453 +
.../portmaster-api/src/lib/spn.service.ts | 171 +
.../portmaster-api/src/lib/spn.types.ts | 104 +
.../safing/portmaster-api/src/lib/utils.ts | 13 +
.../src/lib/websocket.service.ts | 17 +
.../safing/portmaster-api/src/public-api.ts | 22 +
.../safing/portmaster-api/src/test.ts | 15 +
.../safing/portmaster-api/tsconfig.lib.json | 16 +
.../portmaster-api/tsconfig.lib.prod.json | 7 +
.../safing/portmaster-api/tsconfig.spec.json | 18 +
.../angular/projects/safing/ui/.eslintrc.json | 44 +
desktop/angular/projects/safing/ui/README.md | 24 +
.../angular/projects/safing/ui/karma.conf.js | 44 +
.../projects/safing/ui/ng-package.json | 11 +
.../angular/projects/safing/ui/package.json | 17 +
.../ui/src/lib/accordion/accordion-group.html | 1 +
.../ui/src/lib/accordion/accordion-group.ts | 116 +
.../ui/src/lib/accordion/accordion.html | 10 +
.../ui/src/lib/accordion/accordion.module.ts | 19 +
.../safing/ui/src/lib/accordion/accordion.ts | 88 +
.../safing/ui/src/lib/accordion/index.ts | 4 +
.../safing/ui/src/lib/animations/index.ts | 88 +
.../ui/src/lib/dialog/_confirm.dialog.scss | 95 +
.../safing/ui/src/lib/dialog/_dialog.scss | 28 +
.../ui/src/lib/dialog/confirm.dialog.html | 22 +
.../ui/src/lib/dialog/confirm.dialog.ts | 40 +
.../ui/src/lib/dialog/dialog.animations.ts | 19 +
.../ui/src/lib/dialog/dialog.container.ts | 76 +
.../safing/ui/src/lib/dialog/dialog.module.ts | 23 +
.../safing/ui/src/lib/dialog/dialog.ref.ts | 62 +
.../ui/src/lib/dialog/dialog.service.ts | 154 +
.../safing/ui/src/lib/dialog/index.ts | 5 +
.../safing/ui/src/lib/dropdown/dropdown.html | 27 +
.../ui/src/lib/dropdown/dropdown.module.ts | 18 +
.../safing/ui/src/lib/dropdown/dropdown.ts | 216 +
.../safing/ui/src/lib/dropdown/index.ts | 3 +
.../ui/src/lib/overlay-stepper/index.ts | 5 +
.../overlay-stepper-container.html | 22 +
.../overlay-stepper-container.ts | 261 +
.../overlay-stepper/overlay-stepper.module.ts | 21 +
.../lib/overlay-stepper/overlay-stepper.ts | 57 +
.../safing/ui/src/lib/overlay-stepper/refs.ts | 143 +
.../ui/src/lib/overlay-stepper/step-outlet.ts | 90 +
.../safing/ui/src/lib/overlay-stepper/step.ts | 64 +
.../ui/src/lib/pagination/_pagination.scss | 22 +
.../lib/pagination/dynamic-items-paginator.ts | 64 +
.../safing/ui/src/lib/pagination/index.ts | 5 +
.../ui/src/lib/pagination/pagination.html | 33 +
.../src/lib/pagination/pagination.module.ts | 19 +
.../ui/src/lib/pagination/pagination.ts | 132 +
.../src/lib/pagination/snapshot-paginator.ts | 64 +
.../safing/ui/src/lib/select/_select.scss | 73 +
.../safing/ui/src/lib/select/index.ts | 4 +
.../projects/safing/ui/src/lib/select/item.ts | 64 +
.../safing/ui/src/lib/select/select.html | 88 +
.../safing/ui/src/lib/select/select.module.ts | 31 +
.../safing/ui/src/lib/select/select.ts | 495 +
.../safing/ui/src/lib/tabs/_tab-group.scss | 3 +
.../projects/safing/ui/src/lib/tabs/index.ts | 4 +
.../safing/ui/src/lib/tabs/tab-group.html | 24 +
.../safing/ui/src/lib/tabs/tab-group.ts | 352 +
.../projects/safing/ui/src/lib/tabs/tab.ts | 167 +
.../safing/ui/src/lib/tabs/tabs.module.ts | 28 +
.../safing/ui/src/lib/tipup/_tipup.scss | 52 +
.../safing/ui/src/lib/tipup/anchor.ts | 43 +
.../safing/ui/src/lib/tipup/clone-node.ts | 128 +
.../safing/ui/src/lib/tipup/css-utils.ts | 87 +
.../projects/safing/ui/src/lib/tipup/index.ts | 6 +
.../safing/ui/src/lib/tipup/safe.pipe.ts | 21 +
.../ui/src/lib/tipup/tipup-component.ts | 67 +
.../safing/ui/src/lib/tipup/tipup.html | 22 +
.../safing/ui/src/lib/tipup/tipup.module.ts | 47 +
.../projects/safing/ui/src/lib/tipup/tipup.ts | 526 +
.../safing/ui/src/lib/tipup/translations.ts | 27 +
.../projects/safing/ui/src/lib/tipup/utils.ts | 8 +
.../src/lib/toggle-switch/_toggle-switch.scss | 35 +
.../safing/ui/src/lib/toggle-switch/index.ts | 3 +
.../src/lib/toggle-switch/toggle-switch.html | 20 +
.../ui/src/lib/toggle-switch/toggle-switch.ts | 59 +
.../ui/src/lib/toggle-switch/toggle.module.ts | 18 +
.../src/lib/tooltip/_tooltip-component.scss | 5 +
.../safing/ui/src/lib/tooltip/index.ts | 3 +
.../ui/src/lib/tooltip/tooltip-component.html | 6 +
.../ui/src/lib/tooltip/tooltip-component.ts | 139 +
.../ui/src/lib/tooltip/tooltip.module.ts | 23 +
.../safing/ui/src/lib/tooltip/tooltip.ts | 244 +
.../projects/safing/ui/src/lib/ui.module.ts | 10 +
.../projects/safing/ui/src/public-api.ts | 16 +
.../angular/projects/safing/ui/src/test.ts | 16 +
.../angular/projects/safing/ui/theming.scss | 8 +
.../projects/safing/ui/tsconfig.lib.json | 18 +
.../projects/safing/ui/tsconfig.lib.prod.json | 7 +
.../projects/safing/ui/tsconfig.spec.json | 17 +
.../tauri-builtin/src/app/app.component.html | 105 +
.../tauri-builtin/src/app/app.component.ts | 52 +
.../tauri-builtin/src/app/app.config.ts | 12 +
.../angular/projects/tauri-builtin/src/assets | 1 +
.../projects/tauri-builtin/src/favicon.ico | Bin 0 -> 948 bytes
.../projects/tauri-builtin/src/index.html | 13 +
.../projects/tauri-builtin/src/main.ts | 6 +
.../projects/tauri-builtin/src/styles.scss | 7 +
.../projects/tauri-builtin/tsconfig.app.json | 10 +
desktop/angular/proxy.json | 6 +
desktop/angular/src/app/app-routing.module.ts | 68 +
desktop/angular/src/app/app.component.html | 53 +
desktop/angular/src/app/app.component.scss | 114 +
desktop/angular/src/app/app.component.spec.ts | 28 +
desktop/angular/src/app/app.component.ts | 234 +
desktop/angular/src/app/app.module.ts | 240 +
.../angular/src/app/integration/browser.ts | 41 +
.../angular/src/app/integration/electron.ts | 55 +
.../angular/src/app/integration/factory.ts | 22 +
desktop/angular/src/app/integration/index.ts | 2 +
.../src/app/integration/integration.ts | 41 +
.../angular/src/app/integration/taur-app.ts | 216 +
desktop/angular/src/app/intro/index.ts | 1 +
desktop/angular/src/app/intro/intro.module.ts | 36 +
.../src/app/intro/step-1-welcome/index.ts | 1 +
.../intro/step-1-welcome/step-1-welcome.html | 14 +
.../intro/step-1-welcome/step-1-welcome.ts | 22 +
.../src/app/intro/step-2-trackers/index.ts | 1 +
.../step-2-trackers/step-2-trackers.html | 11 +
.../intro/step-2-trackers/step-2-trackers.ts | 48 +
.../angular/src/app/intro/step-3-dns/index.ts | 1 +
.../src/app/intro/step-3-dns/step-3-dns.html | 17 +
.../src/app/intro/step-3-dns/step-3-dns.ts | 106 +
.../src/app/intro/step-4-tipups/index.ts | 1 +
.../intro/step-4-tipups/step-4-tipups.html | 11 +
.../app/intro/step-4-tipups/step-4-tipups.ts | 12 +
desktop/angular/src/app/intro/step.scss | 11 +
.../src/app/layout/navigation/navigation.html | 230 +
.../src/app/layout/navigation/navigation.scss | 98 +
.../src/app/layout/navigation/navigation.ts | 298 +
.../src/app/layout/side-dash/side-dash.html | 10 +
.../src/app/layout/side-dash/side-dash.scss | 11 +
.../src/app/layout/side-dash/side-dash.ts | 13 +
desktop/angular/src/app/package-lock.json | 27 +
desktop/angular/src/app/package.json | 12 +
.../app-insights/app-insights.component.html | 13 +
.../app-insights/app-insights.component.ts | 96 +
.../src/app/pages/app-view/app-view.html | 425 +
.../src/app/pages/app-view/app-view.scss | 3 +
.../src/app/pages/app-view/app-view.ts | 641 +
.../angular/src/app/pages/app-view/index.ts | 3 +
.../merge-profile-dialog.component.html | 36 +
.../merge-profile-dialog.component.ts | 62 +
.../src/app/pages/app-view/overview.html | 193 +
.../src/app/pages/app-view/overview.scss | 54 +
.../src/app/pages/app-view/overview.ts | 305 +
.../qs-history/qs-history.component.html | 12 +
.../qs-history/qs-history.component.scss} | 0
.../qs-history/qs-history.component.ts | 67 +
.../app/pages/app-view/qs-internet/index.ts | 1 +
.../app-view/qs-internet/qs-internet.html | 30 +
.../pages/app-view/qs-internet/qs-internet.ts | 79 +
.../pages/app-view/qs-select-exit/index.ts | 1 +
.../qs-select-exit/qs-select-exit.html | 39 +
.../qs-select-exit/qs-select-exit.scss | 0
.../app-view/qs-select-exit/qs-select-exit.ts | 128 +
.../app/pages/app-view/qs-use-spn/index.ts | 1 +
.../pages/app-view/qs-use-spn/qs-use-spn.html | 42 +
.../pages/app-view/qs-use-spn/qs-use-spn.ts | 97 +
.../dashboard-widget.component.html | 14 +
.../dashboard-widget.component.ts | 30 +
.../pages/dashboard/dashboard.component.html | 281 +
.../pages/dashboard/dashboard.component.scss | 166 +
.../pages/dashboard/dashboard.component.ts | 481 +
.../feature-card/feature-card.component.html | 61 +
.../feature-card/feature-card.component.scss | 60 +
.../feature-card/feature-card.component.ts | 128 +
.../angular/src/app/pages/monitor/index.ts | 1 +
.../src/app/pages/monitor/monitor.html | 46 +
.../src/app/pages/monitor/monitor.scss | 49 +
.../angular/src/app/pages/monitor/monitor.ts | 77 +
desktop/angular/src/app/pages/page.scss | 6 +
.../src/app/pages/settings/settings.html | 26 +
.../src/app/pages/settings/settings.scss | 83 +
.../src/app/pages/settings/settings.ts | 133 +
.../spn/country-details/country-details.html | 154 +
.../spn/country-details/country-details.ts | 217 +
.../app/pages/spn/country-details/index.ts | 1 +
.../spn/country-overlay/country-overlay.html | 25 +
.../spn/country-overlay/country-overlay.scss | 40 +
.../spn/country-overlay/country-overlay.ts | 75 +
.../app/pages/spn/country-overlay/index.ts | 1 +
desktop/angular/src/app/pages/spn/index.ts | 1 +
.../src/app/pages/spn/map-legend/index.ts | 1 +
.../app/pages/spn/map-legend/map-legend.html | 54 +
.../app/pages/spn/map-legend/map-legend.ts | 69 +
.../src/app/pages/spn/map-renderer/index.ts | 1 +
.../pages/spn/map-renderer/map-renderer.ts | 383 +
.../app/pages/spn/map-renderer/map-style.scss | 167 +
.../angular/src/app/pages/spn/map.service.ts | 253 +
.../src/app/pages/spn/node-icon/index.ts | 1 +
.../app/pages/spn/node-icon/node-icon.html | 12 +
.../app/pages/spn/node-icon/node-icon.scss | 38 +
.../src/app/pages/spn/node-icon/node-icon.ts | 44 +
.../src/app/pages/spn/pin-details/index.ts | 1 +
.../pages/spn/pin-details/pin-details.html | 127 +
.../app/pages/spn/pin-details/pin-details.ts | 100 +
.../src/app/pages/spn/pin-list/index.ts | 0
.../src/app/pages/spn/pin-list/pin-list.html | 84 +
.../src/app/pages/spn/pin-list/pin-list.ts | 87 +
.../src/app/pages/spn/pin-overlay/index.ts | 1 +
.../pages/spn/pin-overlay/pin-overlay.html | 117 +
.../pages/spn/pin-overlay/pin-overlay.scss | 4 +
.../app/pages/spn/pin-overlay/pin-overlay.ts | 190 +
.../src/app/pages/spn/pin-route/index.ts | 1 +
.../app/pages/spn/pin-route/pin-route.html | 53 +
.../app/pages/spn/pin-route/pin-route.scss | 67 +
.../src/app/pages/spn/pin-route/pin-route.ts | 46 +
.../pages/spn/spn-feature-carousel/index.ts | 1 +
.../spn-feature-carousel.html | 274 +
.../spn-feature-carousel.scss | 62 +
.../spn-feature-carousel.ts | 83 +
.../angular/src/app/pages/spn/spn-page.html | 102 +
.../angular/src/app/pages/spn/spn-page.scss | 143 +
desktop/angular/src/app/pages/spn/spn-page.ts | 1012 +
.../angular/src/app/pages/spn/spn.module.ts | 69 +
desktop/angular/src/app/pages/spn/utils.ts | 4 +
.../src/app/pages/support/form/index.ts | 1 +
.../app/pages/support/form/support-form.html | 107 +
.../app/pages/support/form/support-form.scss | 253 +
.../app/pages/support/form/support-form.ts | 258 +
.../angular/src/app/pages/support/index.ts | 1 +
.../angular/src/app/pages/support/pages.ts | 175 +
.../pages/support/progress-dialog/index.ts | 1 +
.../progress-dialog/progress-dialog.html | 114 +
.../progress-dialog/progress-dialog.ts | 173 +
.../src/app/pages/support/support.html | 50 +
.../src/app/pages/support/support.scss | 77 +
.../angular/src/app/pages/support/support.ts | 97 +
.../prompt-entrypoint/prompt-entrypoint.ts | 78 +
.../src/app/prompt-entrypoint/prompt.html | 65 +
desktop/angular/src/app/services/index.ts | 8 +
.../services/notifications.service.spec.ts | 354 +
.../src/app/services/notifications.service.ts | 395 +
.../src/app/services/notifications.types.ts | 205 +
desktop/angular/src/app/services/package.json | 3 +
.../src/app/services/session-data.service.ts | 72 +
.../src/app/services/status.service.spec.ts | 16 +
.../src/app/services/status.service.ts | 95 +
.../angular/src/app/services/status.types.ts | 132 +
.../src/app/services/supporthub.service.ts | 82 +
.../src/app/services/ui-state.service.ts | 57 +
.../src/app/services/virtual-notification.ts | 85 +
.../action-indicator.module.ts | 13 +
.../action-indicator.service.ts | 284 +
.../src/app/shared/action-indicator/index.ts | 2 +
.../shared/action-indicator/indicator.html | 30 +
.../shared/action-indicator/indicator.scss | 74 +
.../app/shared/action-indicator/indicator.ts | 78 +
desktop/angular/src/app/shared/animations.ts | 111 +
.../app/shared/app-icon/app-icon-resolver.ts | 118 +
.../src/app/shared/app-icon/app-icon.html | 9 +
.../app/shared/app-icon/app-icon.module.ts | 23 +
.../src/app/shared/app-icon/app-icon.scss | 28 +
.../src/app/shared/app-icon/app-icon.ts | 312 +
.../angular/src/app/shared/app-icon/index.ts | 2 +
.../config/basic-setting/basic-setting.html | 69 +
.../config/basic-setting/basic-setting.scss | 28 +
.../config/basic-setting/basic-setting.ts | 333 +
.../app/shared/config/basic-setting/index.ts | 1 +
.../app/shared/config/config-settings.html | 111 +
.../app/shared/config/config-settings.scss | 95 +
.../src/app/shared/config/config-settings.ts | 606 +
.../src/app/shared/config/config.module.ts | 77 +
.../export-dialog.component.html | 19 +
.../export-dialog/export-dialog.component.ts | 67 +
.../config/filter-lists/filter-list.html | 55 +
.../config/filter-lists/filter-list.scss | 101 +
.../shared/config/filter-lists/filter-list.ts | 293 +
.../app/shared/config/filter-lists/index.ts | 1 +
.../generic-setting/generic-setting.html | 204 +
.../generic-setting/generic-setting.scss | 97 +
.../config/generic-setting/generic-setting.ts | 715 +
.../shared/config/generic-setting/index.ts | 1 +
.../app/shared/config/import-dialog/cursor.ts | 90 +
.../import-dialog.component.html | 99 +
.../import-dialog/import-dialog.component.ts | 201 +
.../shared/config/import-dialog/selection.ts | 185 +
.../angular/src/app/shared/config/index.ts | 8 +
.../app/shared/config/ordererd-list/index.ts | 2 +
.../app/shared/config/ordererd-list/item.html | 14 +
.../app/shared/config/ordererd-list/item.scss | 56 +
.../app/shared/config/ordererd-list/item.ts | 87 +
.../config/ordererd-list/ordered-list.html | 23 +
.../config/ordererd-list/ordered-list.scss | 77 +
.../config/ordererd-list/ordered-list.ts | 111 +
.../src/app/shared/config/rule-list/index.ts | 2 +
.../shared/config/rule-list/list-item.html | 29 +
.../shared/config/rule-list/list-item.scss | 65 +
.../app/shared/config/rule-list/list-item.ts | 221 +
.../shared/config/rule-list/rule-list.html | 46 +
.../shared/config/rule-list/rule-list.scss | 75 +
.../app/shared/config/rule-list/rule-list.ts | 226 +
.../src/app/shared/config/safe.pipe.ts | 21 +
.../count-indicator/count-indicator.html | 4 +
.../count-indicator/count-indicator.module.ts | 15 +
.../count-indicator/count-indicator.scss | 8 +
.../shared/count-indicator/count-indicator.ts | 22 +
.../app/shared/count-indicator/count.pipe.ts | 18 +
.../src/app/shared/count-indicator/index.ts | 2 +
.../app/shared/country-flag/country-flag.ts | 45 +
.../app/shared/country-flag/country.module.ts | 12 +
.../src/app/shared/country-flag/index.ts | 2 +
.../edit-profile-dialog.html | 322 +
.../edit-profile-dialog.scss | 29 +
.../edit-profile-dialog.ts | 393 +
.../app/shared/edit-profile-dialog/index.ts | 1 +
.../app/shared/exit-screen/exit-screen.html | 19 +
.../app/shared/exit-screen/exit-screen.scss | 68 +
.../src/app/shared/exit-screen/exit-screen.ts | 52 +
.../app/shared/exit-screen/exit.service.ts | 146 +
.../src/app/shared/exit-screen/index.ts | 2 +
.../shared/expertise/expertise-directive.ts | 93 +
.../shared/expertise/expertise-switch.html | 16 +
.../shared/expertise/expertise-switch.scss | 12 +
.../app/shared/expertise/expertise-switch.ts | 38 +
.../app/shared/expertise/expertise.module.ts | 24 +
.../app/shared/expertise/expertise.service.ts | 63 +
.../angular/src/app/shared/expertise/index.ts | 3 +
.../src/app/shared/external-link.directive.ts | 53 +
.../shared/feature-scout/feature-scout.html | 106 +
.../shared/feature-scout/feature-scout.scss | 15 +
.../app/shared/feature-scout/feature-scout.ts | 98 +
.../src/app/shared/feature-scout/index.ts | 1 +
.../src/app/shared/focus/focus.directive.ts | 32 +
.../src/app/shared/focus/focus.module.ts | 16 +
desktop/angular/src/app/shared/focus/index.ts | 2 +
.../app/shared/fuzzySearch/fuse.service.ts | 105 +
.../src/app/shared/fuzzySearch/index.ts | 4 +
.../src/app/shared/fuzzySearch/search-pipe.ts | 19 +
.../angular/src/app/shared/loading/index.ts | 1 +
.../src/app/shared/loading/loading.html | 3 +
.../src/app/shared/loading/loading.scss | 52 +
.../angular/src/app/shared/loading/loading.ts | 14 +
desktop/angular/src/app/shared/menu/index.ts | 2 +
.../src/app/shared/menu/menu-group.scss | 13 +
.../src/app/shared/menu/menu-item.scss | 17 +
.../src/app/shared/menu/menu-trigger.html | 14 +
.../src/app/shared/menu/menu-trigger.scss | 41 +
desktop/angular/src/app/shared/menu/menu.html | 6 +
.../src/app/shared/menu/menu.module.ts | 26 +
desktop/angular/src/app/shared/menu/menu.ts | 111 +
.../src/app/shared/multi-switch/index.ts | 3 +
.../app/shared/multi-switch/multi-switch.html | 5 +
.../multi-switch/multi-switch.module.ts | 26 +
.../app/shared/multi-switch/multi-switch.scss | 46 +
.../app/shared/multi-switch/multi-switch.ts | 370 +
.../app/shared/multi-switch/switch-item.scss | 35 +
.../app/shared/multi-switch/switch-item.ts | 80 +
.../src/app/shared/netquery/.eslintrc.json | 44 +
.../netquery/add-to-filter/add-to-filter.ts | 93 +
.../shared/netquery/add-to-filter/index.ts | 1 +
.../circular-bar-chart.component.ts | 358 +
.../app/shared/netquery/combined-menu.pipe.ts | 16 +
.../connection-details/conn-details.html | 322 +
.../connection-details/conn-details.scss | 114 +
.../connection-details/conn-details.ts | 147 +
.../netquery/connection-details/index.ts | 1 +
.../netquery/connection-helper.service.ts | 537 +
.../netquery/connection-row/conn-row.html | 146 +
.../netquery/connection-row/conn-row.scss | 43 +
.../netquery/connection-row/conn-row.ts | 78 +
.../shared/netquery/connection-row/index.ts | 1 +
.../angular/src/app/shared/netquery/index.ts | 2 +
.../app/shared/netquery/line-chart/index.ts | 0
.../shared/netquery/line-chart/line-chart.ts | 592 +
.../shared/netquery/netquery.component.html | 388 +
.../app/shared/netquery/netquery.component.ts | 1270 +
.../app/shared/netquery/netquery.module.ts | 88 +
.../shared/netquery/pipes/can-show.pipe.ts | 22 +
.../netquery/pipes/can-use-rules.pipe.ts | 32 +
.../netquery/pipes/country-name.pipe.ts | 59 +
.../src/app/shared/netquery/pipes/index.ts | 5 +
.../shared/netquery/pipes/is-blocked.pipe.ts | 12 +
.../shared/netquery/pipes/location.pipe.ts | 33 +
.../app/shared/netquery/scope-label/index.ts | 1 +
.../netquery/scope-label/scope-label.html | 8 +
.../netquery/scope-label/scope-label.ts | 34 +
.../shared/netquery/search-overlay/index.ts | 1 +
.../search-overlay/search-overlay.html | 2 +
.../netquery/search-overlay/search-overlay.ts | 81 +
.../app/shared/netquery/searchbar/index.ts | 1 +
.../shared/netquery/searchbar/searchbar.html | 76 +
.../shared/netquery/searchbar/searchbar.ts | 437 +
.../src/app/shared/netquery/tag-bar/index.ts | 1 +
.../app/shared/netquery/tag-bar/tag-bar.html | 26 +
.../app/shared/netquery/tag-bar/tag-bar.ts | 136 +
.../src/app/shared/netquery/textql/helper.ts | 21 +
.../src/app/shared/netquery/textql/index.ts | 1 +
.../src/app/shared/netquery/textql/input.ts | 41 +
.../src/app/shared/netquery/textql/lexer.ts | 255 +
.../src/app/shared/netquery/textql/parser.ts | 204 +
.../src/app/shared/netquery/textql/token.ts | 46 +
.../angular/src/app/shared/netquery/utils.ts | 63 +
.../src/app/shared/network-scout/index.ts | 1 +
.../shared/network-scout/network-scout.html | 182 +
.../shared/network-scout/network-scout.scss | 3 +
.../app/shared/network-scout/network-scout.ts | 322 +
.../src/app/shared/notification-list/index.ts | 1 +
.../notification-list.component.html | 24 +
.../notification-list.component.scss | 186 +
.../notification-list.component.ts | 138 +
.../app/shared/notification/notification.html | 27 +
.../app/shared/notification/notification.scss | 48 +
.../app/shared/notification/notification.ts | 65 +
.../src/app/shared/pipes/bytes.pipe.ts | 28 +
.../app/shared/pipes/common-pipes.module.ts | 27 +
.../src/app/shared/pipes/duration.pipe.ts | 103 +
desktop/angular/src/app/shared/pipes/index.ts | 6 +
.../src/app/shared/pipes/round.pipe.ts | 15 +
.../src/app/shared/pipes/time-ago.pipe.ts | 56 +
.../src/app/shared/pipes/to-profile.pipe.ts | 35 +
.../src/app/shared/pipes/to-seconds.pipe.ts | 19 +
.../shared/process-details-dialog/index.ts | 1 +
.../process-details-dialog.html | 131 +
.../process-details-dialog.scss | 32 +
.../process-details-dialog.ts | 102 +
.../src/app/shared/prompt-list/index.ts | 1 +
.../prompt-list/prompt-list.component.html | 68 +
.../prompt-list/prompt-list.component.scss | 204 +
.../prompt-list/prompt-list.component.ts | 236 +
.../src/app/shared/security-lock/index.ts | 1 +
.../shared/security-lock/security-lock.html | 25 +
.../shared/security-lock/security-lock.scss | 120 +
.../app/shared/security-lock/security-lock.ts | 97 +
.../app/shared/spn-account-details/index.ts | 1 +
.../spn-account-details.html | 101 +
.../spn-account-details.scss | 7 +
.../spn-account-details.ts | 83 +
.../angular/src/app/shared/spn-login/index.ts | 1 +
.../src/app/shared/spn-login/spn-login.html | 70 +
.../src/app/shared/spn-login/spn-login.scss | 53 +
.../src/app/shared/spn-login/spn-login.ts | 70 +
.../app/shared/spn-network-status/index.ts | 1 +
.../spn-network-status.html | 28 +
.../spn-network-status.scss | 71 +
.../spn-network-status/spn-network-status.ts | 65 +
.../src/app/shared/spn-status/index.ts | 1 +
.../src/app/shared/spn-status/spn-status.html | 54 +
.../src/app/shared/spn-status/spn-status.ts | 128 +
.../src/app/shared/status-pilot/index.ts | 1 +
.../app/shared/status-pilot/pilot-widget.html | 57 +
.../app/shared/status-pilot/pilot-widget.scss | 208 +
.../app/shared/status-pilot/pilot-widget.ts | 115 +
.../src/app/shared/text-placeholder/index.ts | 1 +
.../shared/text-placeholder/placeholder.scss | 32 +
.../shared/text-placeholder/placeholder.ts | 61 +
desktop/angular/src/app/shared/utils.ts | 76 +
desktop/angular/src/assets | 1 +
desktop/angular/src/electron-app.d.ts | 41 +
.../src/environments/environment.prod.ts | 22 +
.../angular/src/environments/environment.ts | 19 +
desktop/angular/src/i18n/helptexts.yaml | 370 +
desktop/angular/src/i18n/helptexts.yaml.d.ts | 24 +
desktop/angular/src/index.html | 34 +
desktop/angular/src/main.ts | 94 +
desktop/angular/src/polyfills.ts | 57 +
desktop/angular/src/styles.scss | 120 +
desktop/angular/src/test.ts | 14 +
desktop/angular/src/theme.less | 4 +
desktop/angular/src/theme/_breadcrumbs.scss | 20 +
desktop/angular/src/theme/_button.scss | 58 +
desktop/angular/src/theme/_card.scss | 110 +
desktop/angular/src/theme/_colors.scss | 46 +
desktop/angular/src/theme/_dialog.scss | 9 +
desktop/angular/src/theme/_drag-n-drop.scss | 46 +
desktop/angular/src/theme/_inputs.scss | 35 +
desktop/angular/src/theme/_markdown.scss | 455 +
desktop/angular/src/theme/_pill.scss | 7 +
desktop/angular/src/theme/_scroll.scss | 28 +
desktop/angular/src/theme/_search.scss | 10 +
desktop/angular/src/theme/_table.scss | 41 +
desktop/angular/src/theme/_tailwind.scss | 4 +
desktop/angular/src/theme/_trust-level.scss | 73 +
desktop/angular/src/theme/_typography.scss | 61 +
desktop/angular/src/theme/_verdict.scss | 47 +
desktop/angular/src/theme/mixins/_pill.scss | 42 +
desktop/angular/tailwind.config.js | 127 +
desktop/angular/tsconfig.app.json | 16 +
desktop/angular/tsconfig.json | 41 +
desktop/angular/tsconfig.spec.json | 19 +
desktop/angular/tslint.json | 153 +
922 files changed, 84071 insertions(+), 26 deletions(-)
create mode 100644 assets/fonts/Roboto-300/LICENSE.txt
create mode 100644 assets/fonts/Roboto-300/Roboto-300.eot
create mode 100644 assets/fonts/Roboto-300/Roboto-300.svg
create mode 100644 assets/fonts/Roboto-300/Roboto-300.ttf
create mode 100644 assets/fonts/Roboto-300/Roboto-300.woff
create mode 100644 assets/fonts/Roboto-300/Roboto-300.woff2
create mode 100644 assets/fonts/Roboto-300italic/LICENSE.txt
create mode 100644 assets/fonts/Roboto-300italic/Roboto-300italic.eot
create mode 100644 assets/fonts/Roboto-300italic/Roboto-300italic.svg
create mode 100644 assets/fonts/Roboto-300italic/Roboto-300italic.ttf
create mode 100644 assets/fonts/Roboto-300italic/Roboto-300italic.woff
create mode 100644 assets/fonts/Roboto-300italic/Roboto-300italic.woff2
create mode 100644 assets/fonts/Roboto-500/LICENSE.txt
create mode 100644 assets/fonts/Roboto-500/Roboto-500.eot
create mode 100644 assets/fonts/Roboto-500/Roboto-500.svg
create mode 100644 assets/fonts/Roboto-500/Roboto-500.ttf
create mode 100644 assets/fonts/Roboto-500/Roboto-500.woff
create mode 100644 assets/fonts/Roboto-500/Roboto-500.woff2
create mode 100644 assets/fonts/Roboto-500italic/LICENSE.txt
create mode 100644 assets/fonts/Roboto-500italic/Roboto-500italic.eot
create mode 100644 assets/fonts/Roboto-500italic/Roboto-500italic.svg
create mode 100644 assets/fonts/Roboto-500italic/Roboto-500italic.ttf
create mode 100644 assets/fonts/Roboto-500italic/Roboto-500italic.woff
create mode 100644 assets/fonts/Roboto-500italic/Roboto-500italic.woff2
create mode 100644 assets/fonts/Roboto-700/LICENSE.txt
create mode 100644 assets/fonts/Roboto-700/Roboto-700.eot
create mode 100644 assets/fonts/Roboto-700/Roboto-700.svg
create mode 100644 assets/fonts/Roboto-700/Roboto-700.ttf
create mode 100644 assets/fonts/Roboto-700/Roboto-700.woff
create mode 100644 assets/fonts/Roboto-700/Roboto-700.woff2
create mode 100644 assets/fonts/Roboto-700italic/LICENSE.txt
create mode 100644 assets/fonts/Roboto-700italic/Roboto-700italic.eot
create mode 100644 assets/fonts/Roboto-700italic/Roboto-700italic.svg
create mode 100644 assets/fonts/Roboto-700italic/Roboto-700italic.ttf
create mode 100644 assets/fonts/Roboto-700italic/Roboto-700italic.woff
create mode 100644 assets/fonts/Roboto-700italic/Roboto-700italic.woff2
create mode 100644 assets/fonts/Roboto-italic/LICENSE.txt
create mode 100644 assets/fonts/Roboto-italic/Roboto-italic.eot
create mode 100644 assets/fonts/Roboto-italic/Roboto-italic.svg
create mode 100644 assets/fonts/Roboto-italic/Roboto-italic.ttf
create mode 100644 assets/fonts/Roboto-italic/Roboto-italic.woff
create mode 100644 assets/fonts/Roboto-italic/Roboto-italic.woff2
create mode 100644 assets/fonts/Roboto-regular/LICENSE.txt
create mode 100644 assets/fonts/Roboto-regular/Roboto-regular.eot
create mode 100644 assets/fonts/Roboto-regular/Roboto-regular.svg
create mode 100644 assets/fonts/Roboto-regular/Roboto-regular.ttf
create mode 100644 assets/fonts/Roboto-regular/Roboto-regular.woff
create mode 100644 assets/fonts/Roboto-regular/Roboto-regular.woff2
create mode 100644 assets/fonts/roboto-slimfix.css
create mode 100644 assets/fonts/roboto.css
create mode 100644 assets/icons/README.md
create mode 100644 assets/icons/pm_dark_128.png
create mode 100644 assets/icons/pm_dark_256.png
create mode 100644 assets/icons/pm_dark_512.ico
create mode 100644 assets/icons/pm_dark_512.png
create mode 100644 assets/icons/pm_dark_blue_128.png
create mode 100644 assets/icons/pm_dark_blue_256.png
create mode 100644 assets/icons/pm_dark_blue_512.ico
create mode 100644 assets/icons/pm_dark_blue_512.png
create mode 100644 assets/icons/pm_dark_green_128.png
create mode 100644 assets/icons/pm_dark_green_256.png
create mode 100644 assets/icons/pm_dark_green_512.ico
create mode 100644 assets/icons/pm_dark_green_512.png
create mode 100644 assets/icons/pm_dark_red_128.png
create mode 100644 assets/icons/pm_dark_red_256.png
create mode 100644 assets/icons/pm_dark_red_512.ico
create mode 100644 assets/icons/pm_dark_red_512.png
create mode 100644 assets/icons/pm_dark_yellow_128.png
create mode 100644 assets/icons/pm_dark_yellow_256.png
create mode 100644 assets/icons/pm_dark_yellow_512.ico
create mode 100644 assets/icons/pm_dark_yellow_512.png
create mode 100644 assets/icons/pm_light_128.png
create mode 100644 assets/icons/pm_light_256.png
create mode 100644 assets/icons/pm_light_512.ico
create mode 100644 assets/icons/pm_light_512.png
create mode 100644 assets/icons/pm_light_blue_128.png
create mode 100644 assets/icons/pm_light_blue_256.png
create mode 100644 assets/icons/pm_light_blue_512.ico
create mode 100644 assets/icons/pm_light_blue_512.png
create mode 100644 assets/icons/pm_light_green_128.png
create mode 100644 assets/icons/pm_light_green_256.png
create mode 100644 assets/icons/pm_light_green_512.ico
create mode 100644 assets/icons/pm_light_green_512.png
create mode 100644 assets/icons/pm_light_red_128.png
create mode 100644 assets/icons/pm_light_red_256.png
create mode 100644 assets/icons/pm_light_red_512.ico
create mode 100644 assets/icons/pm_light_red_512.png
create mode 100644 assets/icons/pm_light_yellow_128.png
create mode 100644 assets/icons/pm_light_yellow_256.png
create mode 100644 assets/icons/pm_light_yellow_512.ico
create mode 100644 assets/icons/pm_light_yellow_512.png
create mode 100644 assets/img/Mobile.svg
create mode 100644 assets/img/flags/AD.png
create mode 100644 assets/img/flags/AE.png
create mode 100644 assets/img/flags/AF.png
create mode 100644 assets/img/flags/AG.png
create mode 100644 assets/img/flags/AI.png
create mode 100644 assets/img/flags/AL.png
create mode 100644 assets/img/flags/AM.png
create mode 100644 assets/img/flags/AN.png
create mode 100644 assets/img/flags/AO.png
create mode 100644 assets/img/flags/AQ.png
create mode 100644 assets/img/flags/AR.png
create mode 100644 assets/img/flags/AS.png
create mode 100644 assets/img/flags/AT.png
create mode 100644 assets/img/flags/AU.png
create mode 100644 assets/img/flags/AW.png
create mode 100644 assets/img/flags/AX.png
create mode 100644 assets/img/flags/AZ.png
create mode 100644 assets/img/flags/BA.png
create mode 100644 assets/img/flags/BB.png
create mode 100644 assets/img/flags/BD.png
create mode 100644 assets/img/flags/BE.png
create mode 100644 assets/img/flags/BF.png
create mode 100644 assets/img/flags/BG.png
create mode 100644 assets/img/flags/BH.png
create mode 100644 assets/img/flags/BI.png
create mode 100644 assets/img/flags/BJ.png
create mode 100644 assets/img/flags/BL.png
create mode 100644 assets/img/flags/BM.png
create mode 100644 assets/img/flags/BN.png
create mode 100644 assets/img/flags/BO.png
create mode 100644 assets/img/flags/BR.png
create mode 100644 assets/img/flags/BS.png
create mode 100644 assets/img/flags/BT.png
create mode 100644 assets/img/flags/BW.png
create mode 100644 assets/img/flags/BY.png
create mode 100644 assets/img/flags/BZ.png
create mode 100644 assets/img/flags/CA.png
create mode 100644 assets/img/flags/CC.png
create mode 100644 assets/img/flags/CD.png
create mode 100644 assets/img/flags/CF.png
create mode 100644 assets/img/flags/CG.png
create mode 100644 assets/img/flags/CH.png
create mode 100644 assets/img/flags/CI.png
create mode 100644 assets/img/flags/CK.png
create mode 100644 assets/img/flags/CL.png
create mode 100644 assets/img/flags/CM.png
create mode 100644 assets/img/flags/CN.png
create mode 100644 assets/img/flags/CO.png
create mode 100644 assets/img/flags/CR.png
create mode 100644 assets/img/flags/CT.png
create mode 100644 assets/img/flags/CU.png
create mode 100644 assets/img/flags/CV.png
create mode 100644 assets/img/flags/CW.png
create mode 100644 assets/img/flags/CX.png
create mode 100644 assets/img/flags/CY.png
create mode 100644 assets/img/flags/CZ.png
create mode 100644 assets/img/flags/DE.png
create mode 100644 assets/img/flags/DJ.png
create mode 100644 assets/img/flags/DK.png
create mode 100644 assets/img/flags/DM.png
create mode 100644 assets/img/flags/DO.png
create mode 100644 assets/img/flags/DZ.png
create mode 100644 assets/img/flags/EC.png
create mode 100644 assets/img/flags/EE.png
create mode 100644 assets/img/flags/EG.png
create mode 100644 assets/img/flags/EH.png
create mode 100644 assets/img/flags/ER.png
create mode 100644 assets/img/flags/ES.png
create mode 100644 assets/img/flags/ET.png
create mode 100644 assets/img/flags/EU.png
create mode 100644 assets/img/flags/FI.png
create mode 100644 assets/img/flags/FJ.png
create mode 100644 assets/img/flags/FK.png
create mode 100644 assets/img/flags/FM.png
create mode 100644 assets/img/flags/FO.png
create mode 100644 assets/img/flags/FR.png
create mode 100644 assets/img/flags/GA.png
create mode 100644 assets/img/flags/GB.png
create mode 100644 assets/img/flags/GD.png
create mode 100644 assets/img/flags/GE.png
create mode 100644 assets/img/flags/GG.png
create mode 100644 assets/img/flags/GH.png
create mode 100644 assets/img/flags/GI.png
create mode 100644 assets/img/flags/GL.png
create mode 100644 assets/img/flags/GM.png
create mode 100644 assets/img/flags/GN.png
create mode 100644 assets/img/flags/GQ.png
create mode 100644 assets/img/flags/GR.png
create mode 100644 assets/img/flags/GS.png
create mode 100644 assets/img/flags/GT.png
create mode 100644 assets/img/flags/GU.png
create mode 100644 assets/img/flags/GW.png
create mode 100644 assets/img/flags/GY.png
create mode 100644 assets/img/flags/HK.png
create mode 100644 assets/img/flags/HN.png
create mode 100644 assets/img/flags/HR.png
create mode 100644 assets/img/flags/HT.png
create mode 100644 assets/img/flags/HU.png
create mode 100644 assets/img/flags/IC.png
create mode 100644 assets/img/flags/ID.png
create mode 100644 assets/img/flags/IE.png
create mode 100644 assets/img/flags/IL.png
create mode 100644 assets/img/flags/IM.png
create mode 100644 assets/img/flags/IN.png
create mode 100644 assets/img/flags/IQ.png
create mode 100644 assets/img/flags/IR.png
create mode 100644 assets/img/flags/IS.png
create mode 100644 assets/img/flags/IT.png
create mode 100644 assets/img/flags/JE.png
create mode 100644 assets/img/flags/JM.png
create mode 100644 assets/img/flags/JO.png
create mode 100644 assets/img/flags/JP.png
create mode 100644 assets/img/flags/KE.png
create mode 100644 assets/img/flags/KG.png
create mode 100644 assets/img/flags/KH.png
create mode 100644 assets/img/flags/KI.png
create mode 100644 assets/img/flags/KM.png
create mode 100644 assets/img/flags/KN.png
create mode 100644 assets/img/flags/KP.png
create mode 100644 assets/img/flags/KR.png
create mode 100644 assets/img/flags/KW.png
create mode 100644 assets/img/flags/KY.png
create mode 100644 assets/img/flags/KZ.png
create mode 100644 assets/img/flags/LA.png
create mode 100644 assets/img/flags/LB.png
create mode 100644 assets/img/flags/LC.png
create mode 100644 assets/img/flags/LI.png
create mode 100644 assets/img/flags/LICENSE.txt
create mode 100644 assets/img/flags/LK.png
create mode 100644 assets/img/flags/LR.png
create mode 100644 assets/img/flags/LS.png
create mode 100644 assets/img/flags/LT.png
create mode 100644 assets/img/flags/LU.png
create mode 100644 assets/img/flags/LV.png
create mode 100644 assets/img/flags/LY.png
create mode 100644 assets/img/flags/MA.png
create mode 100644 assets/img/flags/MC.png
create mode 100644 assets/img/flags/MD.png
create mode 100644 assets/img/flags/ME.png
create mode 100644 assets/img/flags/MF.png
create mode 100644 assets/img/flags/MG.png
create mode 100644 assets/img/flags/MH.png
create mode 100644 assets/img/flags/MK.png
create mode 100644 assets/img/flags/ML.png
create mode 100644 assets/img/flags/MM.png
create mode 100644 assets/img/flags/MN.png
create mode 100644 assets/img/flags/MO.png
create mode 100644 assets/img/flags/MP.png
create mode 100644 assets/img/flags/MQ.png
create mode 100644 assets/img/flags/MR.png
create mode 100644 assets/img/flags/MS.png
create mode 100644 assets/img/flags/MT.png
create mode 100644 assets/img/flags/MU.png
create mode 100644 assets/img/flags/MV.png
create mode 100644 assets/img/flags/MW.png
create mode 100644 assets/img/flags/MX.png
create mode 100644 assets/img/flags/MY.png
create mode 100644 assets/img/flags/MZ.png
create mode 100644 assets/img/flags/NA.png
create mode 100644 assets/img/flags/NC.png
create mode 100644 assets/img/flags/NE.png
create mode 100644 assets/img/flags/NF.png
create mode 100644 assets/img/flags/NG.png
create mode 100644 assets/img/flags/NI.png
create mode 100644 assets/img/flags/NL.png
create mode 100644 assets/img/flags/NO.png
create mode 100644 assets/img/flags/NP.png
create mode 100644 assets/img/flags/NR.png
create mode 100644 assets/img/flags/NU.png
create mode 100644 assets/img/flags/NZ.png
create mode 100644 assets/img/flags/OM.png
create mode 100644 assets/img/flags/PA.png
create mode 100644 assets/img/flags/PE.png
create mode 100644 assets/img/flags/PF.png
create mode 100644 assets/img/flags/PG.png
create mode 100644 assets/img/flags/PH.png
create mode 100644 assets/img/flags/PK.png
create mode 100644 assets/img/flags/PL.png
create mode 100644 assets/img/flags/PN.png
create mode 100644 assets/img/flags/PR.png
create mode 100644 assets/img/flags/PS.png
create mode 100644 assets/img/flags/PT.png
create mode 100644 assets/img/flags/PW.png
create mode 100644 assets/img/flags/PY.png
create mode 100644 assets/img/flags/QA.png
create mode 100644 assets/img/flags/RE.png
create mode 100644 assets/img/flags/RO.png
create mode 100644 assets/img/flags/RS.png
create mode 100644 assets/img/flags/RU.png
create mode 100644 assets/img/flags/RW.png
create mode 100644 assets/img/flags/SA.png
create mode 100644 assets/img/flags/SB.png
create mode 100644 assets/img/flags/SC.png
create mode 100644 assets/img/flags/SD.png
create mode 100644 assets/img/flags/SE.png
create mode 100644 assets/img/flags/SG.png
create mode 100644 assets/img/flags/SH.png
create mode 100644 assets/img/flags/SI.png
create mode 100644 assets/img/flags/SK.png
create mode 100644 assets/img/flags/SL.png
create mode 100644 assets/img/flags/SM.png
create mode 100644 assets/img/flags/SN.png
create mode 100644 assets/img/flags/SO.png
create mode 100644 assets/img/flags/SR.png
create mode 100644 assets/img/flags/SS.png
create mode 100644 assets/img/flags/ST.png
create mode 100644 assets/img/flags/SV.png
create mode 100644 assets/img/flags/SX.png
create mode 100644 assets/img/flags/SY.png
create mode 100644 assets/img/flags/SZ.png
create mode 100644 assets/img/flags/TC.png
create mode 100644 assets/img/flags/TD.png
create mode 100644 assets/img/flags/TF.png
create mode 100644 assets/img/flags/TG.png
create mode 100644 assets/img/flags/TH.png
create mode 100644 assets/img/flags/TJ.png
create mode 100644 assets/img/flags/TK.png
create mode 100644 assets/img/flags/TL.png
create mode 100644 assets/img/flags/TM.png
create mode 100644 assets/img/flags/TN.png
create mode 100644 assets/img/flags/TO.png
create mode 100644 assets/img/flags/TR.png
create mode 100644 assets/img/flags/TT.png
create mode 100644 assets/img/flags/TV.png
create mode 100644 assets/img/flags/TW.png
create mode 100644 assets/img/flags/TZ.png
create mode 100644 assets/img/flags/UA.png
create mode 100644 assets/img/flags/UG.png
create mode 100644 assets/img/flags/US.png
create mode 100644 assets/img/flags/UY.png
create mode 100644 assets/img/flags/UZ.png
create mode 100644 assets/img/flags/VA.png
create mode 100644 assets/img/flags/VC.png
create mode 100644 assets/img/flags/VE.png
create mode 100644 assets/img/flags/VG.png
create mode 100644 assets/img/flags/VI.png
create mode 100644 assets/img/flags/VN.png
create mode 100644 assets/img/flags/VU.png
create mode 100644 assets/img/flags/WF.png
create mode 100644 assets/img/flags/WS.png
create mode 100644 assets/img/flags/YE.png
create mode 100644 assets/img/flags/YT.png
create mode 100644 assets/img/flags/ZA.png
create mode 100644 assets/img/flags/ZM.png
create mode 100644 assets/img/flags/ZW.png
create mode 100644 assets/img/flags/_abkhazia.png
create mode 100644 assets/img/flags/_basque-country.png
create mode 100644 assets/img/flags/_british-antarctic-territory.png
create mode 100644 assets/img/flags/_commonwealth.png
create mode 100644 assets/img/flags/_england.png
create mode 100644 assets/img/flags/_gosquared.png
create mode 100644 assets/img/flags/_kosovo.png
create mode 100644 assets/img/flags/_mars.png
create mode 100644 assets/img/flags/_nagorno-karabakh.png
create mode 100644 assets/img/flags/_nato.png
create mode 100644 assets/img/flags/_northern-cyprus.png
create mode 100644 assets/img/flags/_olympics.png
create mode 100644 assets/img/flags/_red-cross.png
create mode 100644 assets/img/flags/_scotland.png
create mode 100644 assets/img/flags/_somaliland.png
create mode 100644 assets/img/flags/_south-ossetia.png
create mode 100644 assets/img/flags/_united-nations.png
create mode 100644 assets/img/flags/_unknown.png
create mode 100644 assets/img/flags/_wales.png
create mode 100755 assets/img/linux.svg
create mode 100755 assets/img/mac.svg
create mode 100644 assets/img/plants1-br.png
create mode 100644 assets/img/plants1.png
create mode 100644 assets/img/spn-feature-carousel/access-regional-content-easily.png
create mode 100644 assets/img/spn-feature-carousel/built-from-the-ground-up.png
create mode 100644 assets/img/spn-feature-carousel/bye-bye-vpns.png
create mode 100644 assets/img/spn-feature-carousel/easily-control-your-privacy.png
create mode 100644 assets/img/spn-feature-carousel/multiple-identities-for-each-app.png
create mode 100644 assets/img/spn-login.png
create mode 100755 assets/img/windows.svg
create mode 100644 assets/world-50m.json
create mode 100644 desktop/angular/README.md
create mode 100644 desktop/angular/angular.json
create mode 120000 desktop/angular/assets
create mode 100644 desktop/angular/browser-extension-dev.config.ts
create mode 100644 desktop/angular/browser-extension.config.ts
create mode 100755 desktop/angular/docker.sh
create mode 100644 desktop/angular/e2e/protractor.conf.js
create mode 100644 desktop/angular/e2e/src/app.e2e-spec.ts
create mode 100644 desktop/angular/e2e/src/app.po.ts
create mode 100644 desktop/angular/e2e/tsconfig.json
create mode 100644 desktop/angular/karma.conf.js
create mode 100644 desktop/angular/package-lock.json
create mode 100644 desktop/angular/package.json
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/karma.conf.js
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/app-routing.module.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.html
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.scss
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/app.component.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/app.module.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.html
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/domain-list.component.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/domain-list/index.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.html
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.scss
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/header/header.component.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/header/index.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/interceptor.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/request-interceptor.service.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/index.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.html
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/intro.component.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/app/welcome/welcome.module.ts
rename {assets => desktop/angular/projects/portmaster-chrome-extension/src/assets}/.gitkeep (100%)
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/assets/icon_128.png
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/background.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/background/commands.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/background/tab-tracker.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/background/tab-utils.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.prod.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/environments/environment.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/favicon.ico
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/index.html
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/main.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/manifest.json
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/polyfills.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/styles.scss
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/src/test.ts
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/tsconfig.app.json
create mode 100644 desktop/angular/projects/portmaster-chrome-extension/tsconfig.spec.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/README.md
create mode 100644 desktop/angular/projects/safing/portmaster-api/karma.conf.js
create mode 100644 desktop/angular/projects/safing/portmaster-api/ng-package.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/package-lock.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/package.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/app-profile.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/config.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/config.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/core.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/debug-api.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/features.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/meta-api.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/module.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/netquery.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/network.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/portapi.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/portapi.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/spn.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/spn.types.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/utils.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/lib/websocket.service.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/public-api.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/src/test.ts
create mode 100644 desktop/angular/projects/safing/portmaster-api/tsconfig.lib.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/tsconfig.lib.prod.json
create mode 100644 desktop/angular/projects/safing/portmaster-api/tsconfig.spec.json
create mode 100644 desktop/angular/projects/safing/ui/.eslintrc.json
create mode 100644 desktop/angular/projects/safing/ui/README.md
create mode 100644 desktop/angular/projects/safing/ui/karma.conf.js
create mode 100644 desktop/angular/projects/safing/ui/ng-package.json
create mode 100644 desktop/angular/projects/safing/ui/package.json
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/accordion-group.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/accordion.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/accordion.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/accordion.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/accordion/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/animations/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/_confirm.dialog.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/_dialog.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/confirm.dialog.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/dialog.animations.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/dialog.container.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/dialog.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/dialog.ref.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/dialog.service.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dialog/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/dropdown/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper-container.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/overlay-stepper.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/refs.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step-outlet.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/overlay-stepper/step.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/_pagination.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/dynamic-items-paginator.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/pagination.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/pagination.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/pagination.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/pagination/snapshot-paginator.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/_select.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/item.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/select.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/select.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/select/select.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/_tab-group.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tabs/tabs.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/css-utils.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/safe.pipe.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/tipup-component.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/tipup.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/translations.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tipup/utils.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/toggle-switch/_toggle-switch.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/toggle-switch/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle-switch.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/toggle-switch/toggle.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/_tooltip-component.scss
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/index.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.html
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip-component.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts
create mode 100644 desktop/angular/projects/safing/ui/src/lib/ui.module.ts
create mode 100644 desktop/angular/projects/safing/ui/src/public-api.ts
create mode 100644 desktop/angular/projects/safing/ui/src/test.ts
create mode 100644 desktop/angular/projects/safing/ui/theming.scss
create mode 100644 desktop/angular/projects/safing/ui/tsconfig.lib.json
create mode 100644 desktop/angular/projects/safing/ui/tsconfig.lib.prod.json
create mode 100644 desktop/angular/projects/safing/ui/tsconfig.spec.json
create mode 100644 desktop/angular/projects/tauri-builtin/src/app/app.component.html
create mode 100644 desktop/angular/projects/tauri-builtin/src/app/app.component.ts
create mode 100644 desktop/angular/projects/tauri-builtin/src/app/app.config.ts
create mode 120000 desktop/angular/projects/tauri-builtin/src/assets
create mode 100644 desktop/angular/projects/tauri-builtin/src/favicon.ico
create mode 100644 desktop/angular/projects/tauri-builtin/src/index.html
create mode 100644 desktop/angular/projects/tauri-builtin/src/main.ts
create mode 100644 desktop/angular/projects/tauri-builtin/src/styles.scss
create mode 100644 desktop/angular/projects/tauri-builtin/tsconfig.app.json
create mode 100644 desktop/angular/proxy.json
create mode 100644 desktop/angular/src/app/app-routing.module.ts
create mode 100644 desktop/angular/src/app/app.component.html
create mode 100644 desktop/angular/src/app/app.component.scss
create mode 100644 desktop/angular/src/app/app.component.spec.ts
create mode 100644 desktop/angular/src/app/app.component.ts
create mode 100644 desktop/angular/src/app/app.module.ts
create mode 100644 desktop/angular/src/app/integration/browser.ts
create mode 100644 desktop/angular/src/app/integration/electron.ts
create mode 100644 desktop/angular/src/app/integration/factory.ts
create mode 100644 desktop/angular/src/app/integration/index.ts
create mode 100644 desktop/angular/src/app/integration/integration.ts
create mode 100644 desktop/angular/src/app/integration/taur-app.ts
create mode 100644 desktop/angular/src/app/intro/index.ts
create mode 100644 desktop/angular/src/app/intro/intro.module.ts
create mode 100644 desktop/angular/src/app/intro/step-1-welcome/index.ts
create mode 100644 desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.html
create mode 100644 desktop/angular/src/app/intro/step-1-welcome/step-1-welcome.ts
create mode 100644 desktop/angular/src/app/intro/step-2-trackers/index.ts
create mode 100644 desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.html
create mode 100644 desktop/angular/src/app/intro/step-2-trackers/step-2-trackers.ts
create mode 100644 desktop/angular/src/app/intro/step-3-dns/index.ts
create mode 100644 desktop/angular/src/app/intro/step-3-dns/step-3-dns.html
create mode 100644 desktop/angular/src/app/intro/step-3-dns/step-3-dns.ts
create mode 100644 desktop/angular/src/app/intro/step-4-tipups/index.ts
create mode 100644 desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.html
create mode 100644 desktop/angular/src/app/intro/step-4-tipups/step-4-tipups.ts
create mode 100644 desktop/angular/src/app/intro/step.scss
create mode 100644 desktop/angular/src/app/layout/navigation/navigation.html
create mode 100644 desktop/angular/src/app/layout/navigation/navigation.scss
create mode 100644 desktop/angular/src/app/layout/navigation/navigation.ts
create mode 100644 desktop/angular/src/app/layout/side-dash/side-dash.html
create mode 100644 desktop/angular/src/app/layout/side-dash/side-dash.scss
create mode 100644 desktop/angular/src/app/layout/side-dash/side-dash.ts
create mode 100644 desktop/angular/src/app/package-lock.json
create mode 100644 desktop/angular/src/app/package.json
create mode 100644 desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.html
create mode 100644 desktop/angular/src/app/pages/app-view/app-insights/app-insights.component.ts
create mode 100644 desktop/angular/src/app/pages/app-view/app-view.html
create mode 100644 desktop/angular/src/app/pages/app-view/app-view.scss
create mode 100644 desktop/angular/src/app/pages/app-view/app-view.ts
create mode 100644 desktop/angular/src/app/pages/app-view/index.ts
create mode 100644 desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.html
create mode 100644 desktop/angular/src/app/pages/app-view/merge-profile-dialog/merge-profile-dialog.component.ts
create mode 100644 desktop/angular/src/app/pages/app-view/overview.html
create mode 100644 desktop/angular/src/app/pages/app-view/overview.scss
create mode 100644 desktop/angular/src/app/pages/app-view/overview.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.html
rename desktop/angular/{.gitkeep => src/app/pages/app-view/qs-history/qs-history.component.scss} (100%)
create mode 100644 desktop/angular/src/app/pages/app-view/qs-history/qs-history.component.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-internet/index.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.html
create mode 100644 desktop/angular/src/app/pages/app-view/qs-internet/qs-internet.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-select-exit/index.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.html
create mode 100644 desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.scss
create mode 100644 desktop/angular/src/app/pages/app-view/qs-select-exit/qs-select-exit.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-use-spn/index.ts
create mode 100644 desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.html
create mode 100644 desktop/angular/src/app/pages/app-view/qs-use-spn/qs-use-spn.ts
create mode 100644 desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.html
create mode 100644 desktop/angular/src/app/pages/dashboard/dashboard-widget/dashboard-widget.component.ts
create mode 100644 desktop/angular/src/app/pages/dashboard/dashboard.component.html
create mode 100644 desktop/angular/src/app/pages/dashboard/dashboard.component.scss
create mode 100644 desktop/angular/src/app/pages/dashboard/dashboard.component.ts
create mode 100644 desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.html
create mode 100644 desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.scss
create mode 100644 desktop/angular/src/app/pages/dashboard/feature-card/feature-card.component.ts
create mode 100644 desktop/angular/src/app/pages/monitor/index.ts
create mode 100644 desktop/angular/src/app/pages/monitor/monitor.html
create mode 100644 desktop/angular/src/app/pages/monitor/monitor.scss
create mode 100644 desktop/angular/src/app/pages/monitor/monitor.ts
create mode 100644 desktop/angular/src/app/pages/page.scss
create mode 100644 desktop/angular/src/app/pages/settings/settings.html
create mode 100644 desktop/angular/src/app/pages/settings/settings.scss
create mode 100644 desktop/angular/src/app/pages/settings/settings.ts
create mode 100644 desktop/angular/src/app/pages/spn/country-details/country-details.html
create mode 100644 desktop/angular/src/app/pages/spn/country-details/country-details.ts
create mode 100644 desktop/angular/src/app/pages/spn/country-details/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/country-overlay/country-overlay.html
create mode 100644 desktop/angular/src/app/pages/spn/country-overlay/country-overlay.scss
create mode 100644 desktop/angular/src/app/pages/spn/country-overlay/country-overlay.ts
create mode 100644 desktop/angular/src/app/pages/spn/country-overlay/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/map-legend/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/map-legend/map-legend.html
create mode 100644 desktop/angular/src/app/pages/spn/map-legend/map-legend.ts
create mode 100644 desktop/angular/src/app/pages/spn/map-renderer/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/map-renderer/map-renderer.ts
create mode 100644 desktop/angular/src/app/pages/spn/map-renderer/map-style.scss
create mode 100644 desktop/angular/src/app/pages/spn/map.service.ts
create mode 100644 desktop/angular/src/app/pages/spn/node-icon/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/node-icon/node-icon.html
create mode 100644 desktop/angular/src/app/pages/spn/node-icon/node-icon.scss
create mode 100644 desktop/angular/src/app/pages/spn/node-icon/node-icon.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-details/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-details/pin-details.html
create mode 100644 desktop/angular/src/app/pages/spn/pin-details/pin-details.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-list/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-list/pin-list.html
create mode 100644 desktop/angular/src/app/pages/spn/pin-list/pin-list.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-overlay/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.html
create mode 100644 desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.scss
create mode 100644 desktop/angular/src/app/pages/spn/pin-overlay/pin-overlay.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-route/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/pin-route/pin-route.html
create mode 100644 desktop/angular/src/app/pages/spn/pin-route/pin-route.scss
create mode 100644 desktop/angular/src/app/pages/spn/pin-route/pin-route.ts
create mode 100644 desktop/angular/src/app/pages/spn/spn-feature-carousel/index.ts
create mode 100644 desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.html
create mode 100644 desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.scss
create mode 100644 desktop/angular/src/app/pages/spn/spn-feature-carousel/spn-feature-carousel.ts
create mode 100644 desktop/angular/src/app/pages/spn/spn-page.html
create mode 100644 desktop/angular/src/app/pages/spn/spn-page.scss
create mode 100644 desktop/angular/src/app/pages/spn/spn-page.ts
create mode 100644 desktop/angular/src/app/pages/spn/spn.module.ts
create mode 100644 desktop/angular/src/app/pages/spn/utils.ts
create mode 100644 desktop/angular/src/app/pages/support/form/index.ts
create mode 100644 desktop/angular/src/app/pages/support/form/support-form.html
create mode 100644 desktop/angular/src/app/pages/support/form/support-form.scss
create mode 100644 desktop/angular/src/app/pages/support/form/support-form.ts
create mode 100644 desktop/angular/src/app/pages/support/index.ts
create mode 100644 desktop/angular/src/app/pages/support/pages.ts
create mode 100644 desktop/angular/src/app/pages/support/progress-dialog/index.ts
create mode 100644 desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.html
create mode 100644 desktop/angular/src/app/pages/support/progress-dialog/progress-dialog.ts
create mode 100644 desktop/angular/src/app/pages/support/support.html
create mode 100644 desktop/angular/src/app/pages/support/support.scss
create mode 100644 desktop/angular/src/app/pages/support/support.ts
create mode 100644 desktop/angular/src/app/prompt-entrypoint/prompt-entrypoint.ts
create mode 100644 desktop/angular/src/app/prompt-entrypoint/prompt.html
create mode 100644 desktop/angular/src/app/services/index.ts
create mode 100644 desktop/angular/src/app/services/notifications.service.spec.ts
create mode 100644 desktop/angular/src/app/services/notifications.service.ts
create mode 100644 desktop/angular/src/app/services/notifications.types.ts
create mode 100644 desktop/angular/src/app/services/package.json
create mode 100644 desktop/angular/src/app/services/session-data.service.ts
create mode 100644 desktop/angular/src/app/services/status.service.spec.ts
create mode 100644 desktop/angular/src/app/services/status.service.ts
create mode 100644 desktop/angular/src/app/services/status.types.ts
create mode 100644 desktop/angular/src/app/services/supporthub.service.ts
create mode 100644 desktop/angular/src/app/services/ui-state.service.ts
create mode 100644 desktop/angular/src/app/services/virtual-notification.ts
create mode 100644 desktop/angular/src/app/shared/action-indicator/action-indicator.module.ts
create mode 100644 desktop/angular/src/app/shared/action-indicator/action-indicator.service.ts
create mode 100644 desktop/angular/src/app/shared/action-indicator/index.ts
create mode 100644 desktop/angular/src/app/shared/action-indicator/indicator.html
create mode 100644 desktop/angular/src/app/shared/action-indicator/indicator.scss
create mode 100644 desktop/angular/src/app/shared/action-indicator/indicator.ts
create mode 100644 desktop/angular/src/app/shared/animations.ts
create mode 100644 desktop/angular/src/app/shared/app-icon/app-icon-resolver.ts
create mode 100644 desktop/angular/src/app/shared/app-icon/app-icon.html
create mode 100644 desktop/angular/src/app/shared/app-icon/app-icon.module.ts
create mode 100644 desktop/angular/src/app/shared/app-icon/app-icon.scss
create mode 100644 desktop/angular/src/app/shared/app-icon/app-icon.ts
create mode 100644 desktop/angular/src/app/shared/app-icon/index.ts
create mode 100644 desktop/angular/src/app/shared/config/basic-setting/basic-setting.html
create mode 100644 desktop/angular/src/app/shared/config/basic-setting/basic-setting.scss
create mode 100644 desktop/angular/src/app/shared/config/basic-setting/basic-setting.ts
create mode 100644 desktop/angular/src/app/shared/config/basic-setting/index.ts
create mode 100644 desktop/angular/src/app/shared/config/config-settings.html
create mode 100644 desktop/angular/src/app/shared/config/config-settings.scss
create mode 100644 desktop/angular/src/app/shared/config/config-settings.ts
create mode 100644 desktop/angular/src/app/shared/config/config.module.ts
create mode 100644 desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.html
create mode 100644 desktop/angular/src/app/shared/config/export-dialog/export-dialog.component.ts
create mode 100644 desktop/angular/src/app/shared/config/filter-lists/filter-list.html
create mode 100644 desktop/angular/src/app/shared/config/filter-lists/filter-list.scss
create mode 100644 desktop/angular/src/app/shared/config/filter-lists/filter-list.ts
create mode 100644 desktop/angular/src/app/shared/config/filter-lists/index.ts
create mode 100644 desktop/angular/src/app/shared/config/generic-setting/generic-setting.html
create mode 100644 desktop/angular/src/app/shared/config/generic-setting/generic-setting.scss
create mode 100644 desktop/angular/src/app/shared/config/generic-setting/generic-setting.ts
create mode 100644 desktop/angular/src/app/shared/config/generic-setting/index.ts
create mode 100644 desktop/angular/src/app/shared/config/import-dialog/cursor.ts
create mode 100644 desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.html
create mode 100644 desktop/angular/src/app/shared/config/import-dialog/import-dialog.component.ts
create mode 100644 desktop/angular/src/app/shared/config/import-dialog/selection.ts
create mode 100644 desktop/angular/src/app/shared/config/index.ts
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/index.ts
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/item.html
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/item.scss
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/item.ts
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/ordered-list.html
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/ordered-list.scss
create mode 100644 desktop/angular/src/app/shared/config/ordererd-list/ordered-list.ts
create mode 100644 desktop/angular/src/app/shared/config/rule-list/index.ts
create mode 100644 desktop/angular/src/app/shared/config/rule-list/list-item.html
create mode 100644 desktop/angular/src/app/shared/config/rule-list/list-item.scss
create mode 100644 desktop/angular/src/app/shared/config/rule-list/list-item.ts
create mode 100644 desktop/angular/src/app/shared/config/rule-list/rule-list.html
create mode 100644 desktop/angular/src/app/shared/config/rule-list/rule-list.scss
create mode 100644 desktop/angular/src/app/shared/config/rule-list/rule-list.ts
create mode 100644 desktop/angular/src/app/shared/config/safe.pipe.ts
create mode 100644 desktop/angular/src/app/shared/count-indicator/count-indicator.html
create mode 100644 desktop/angular/src/app/shared/count-indicator/count-indicator.module.ts
create mode 100644 desktop/angular/src/app/shared/count-indicator/count-indicator.scss
create mode 100644 desktop/angular/src/app/shared/count-indicator/count-indicator.ts
create mode 100644 desktop/angular/src/app/shared/count-indicator/count.pipe.ts
create mode 100644 desktop/angular/src/app/shared/count-indicator/index.ts
create mode 100644 desktop/angular/src/app/shared/country-flag/country-flag.ts
create mode 100644 desktop/angular/src/app/shared/country-flag/country.module.ts
create mode 100644 desktop/angular/src/app/shared/country-flag/index.ts
create mode 100644 desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.html
create mode 100644 desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.scss
create mode 100644 desktop/angular/src/app/shared/edit-profile-dialog/edit-profile-dialog.ts
create mode 100644 desktop/angular/src/app/shared/edit-profile-dialog/index.ts
create mode 100644 desktop/angular/src/app/shared/exit-screen/exit-screen.html
create mode 100644 desktop/angular/src/app/shared/exit-screen/exit-screen.scss
create mode 100644 desktop/angular/src/app/shared/exit-screen/exit-screen.ts
create mode 100644 desktop/angular/src/app/shared/exit-screen/exit.service.ts
create mode 100644 desktop/angular/src/app/shared/exit-screen/index.ts
create mode 100644 desktop/angular/src/app/shared/expertise/expertise-directive.ts
create mode 100644 desktop/angular/src/app/shared/expertise/expertise-switch.html
create mode 100644 desktop/angular/src/app/shared/expertise/expertise-switch.scss
create mode 100644 desktop/angular/src/app/shared/expertise/expertise-switch.ts
create mode 100644 desktop/angular/src/app/shared/expertise/expertise.module.ts
create mode 100644 desktop/angular/src/app/shared/expertise/expertise.service.ts
create mode 100644 desktop/angular/src/app/shared/expertise/index.ts
create mode 100644 desktop/angular/src/app/shared/external-link.directive.ts
create mode 100644 desktop/angular/src/app/shared/feature-scout/feature-scout.html
create mode 100644 desktop/angular/src/app/shared/feature-scout/feature-scout.scss
create mode 100644 desktop/angular/src/app/shared/feature-scout/feature-scout.ts
create mode 100644 desktop/angular/src/app/shared/feature-scout/index.ts
create mode 100644 desktop/angular/src/app/shared/focus/focus.directive.ts
create mode 100644 desktop/angular/src/app/shared/focus/focus.module.ts
create mode 100644 desktop/angular/src/app/shared/focus/index.ts
create mode 100644 desktop/angular/src/app/shared/fuzzySearch/fuse.service.ts
create mode 100644 desktop/angular/src/app/shared/fuzzySearch/index.ts
create mode 100644 desktop/angular/src/app/shared/fuzzySearch/search-pipe.ts
create mode 100644 desktop/angular/src/app/shared/loading/index.ts
create mode 100644 desktop/angular/src/app/shared/loading/loading.html
create mode 100644 desktop/angular/src/app/shared/loading/loading.scss
create mode 100644 desktop/angular/src/app/shared/loading/loading.ts
create mode 100644 desktop/angular/src/app/shared/menu/index.ts
create mode 100644 desktop/angular/src/app/shared/menu/menu-group.scss
create mode 100644 desktop/angular/src/app/shared/menu/menu-item.scss
create mode 100644 desktop/angular/src/app/shared/menu/menu-trigger.html
create mode 100644 desktop/angular/src/app/shared/menu/menu-trigger.scss
create mode 100644 desktop/angular/src/app/shared/menu/menu.html
create mode 100644 desktop/angular/src/app/shared/menu/menu.module.ts
create mode 100644 desktop/angular/src/app/shared/menu/menu.ts
create mode 100644 desktop/angular/src/app/shared/multi-switch/index.ts
create mode 100644 desktop/angular/src/app/shared/multi-switch/multi-switch.html
create mode 100644 desktop/angular/src/app/shared/multi-switch/multi-switch.module.ts
create mode 100644 desktop/angular/src/app/shared/multi-switch/multi-switch.scss
create mode 100644 desktop/angular/src/app/shared/multi-switch/multi-switch.ts
create mode 100644 desktop/angular/src/app/shared/multi-switch/switch-item.scss
create mode 100644 desktop/angular/src/app/shared/multi-switch/switch-item.ts
create mode 100644 desktop/angular/src/app/shared/netquery/.eslintrc.json
create mode 100644 desktop/angular/src/app/shared/netquery/add-to-filter/add-to-filter.ts
create mode 100644 desktop/angular/src/app/shared/netquery/add-to-filter/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/circular-bar-chart/circular-bar-chart.component.ts
create mode 100644 desktop/angular/src/app/shared/netquery/combined-menu.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/connection-details/conn-details.html
create mode 100644 desktop/angular/src/app/shared/netquery/connection-details/conn-details.scss
create mode 100644 desktop/angular/src/app/shared/netquery/connection-details/conn-details.ts
create mode 100644 desktop/angular/src/app/shared/netquery/connection-details/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/connection-helper.service.ts
create mode 100644 desktop/angular/src/app/shared/netquery/connection-row/conn-row.html
create mode 100644 desktop/angular/src/app/shared/netquery/connection-row/conn-row.scss
create mode 100644 desktop/angular/src/app/shared/netquery/connection-row/conn-row.ts
create mode 100644 desktop/angular/src/app/shared/netquery/connection-row/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/line-chart/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/line-chart/line-chart.ts
create mode 100644 desktop/angular/src/app/shared/netquery/netquery.component.html
create mode 100644 desktop/angular/src/app/shared/netquery/netquery.component.ts
create mode 100644 desktop/angular/src/app/shared/netquery/netquery.module.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/can-show.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/can-use-rules.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/country-name.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/is-blocked.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/pipes/location.pipe.ts
create mode 100644 desktop/angular/src/app/shared/netquery/scope-label/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/scope-label/scope-label.html
create mode 100644 desktop/angular/src/app/shared/netquery/scope-label/scope-label.ts
create mode 100644 desktop/angular/src/app/shared/netquery/search-overlay/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.html
create mode 100644 desktop/angular/src/app/shared/netquery/search-overlay/search-overlay.ts
create mode 100644 desktop/angular/src/app/shared/netquery/searchbar/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/searchbar/searchbar.html
create mode 100644 desktop/angular/src/app/shared/netquery/searchbar/searchbar.ts
create mode 100644 desktop/angular/src/app/shared/netquery/tag-bar/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.html
create mode 100644 desktop/angular/src/app/shared/netquery/tag-bar/tag-bar.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/helper.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/index.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/input.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/lexer.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/parser.ts
create mode 100644 desktop/angular/src/app/shared/netquery/textql/token.ts
create mode 100644 desktop/angular/src/app/shared/netquery/utils.ts
create mode 100644 desktop/angular/src/app/shared/network-scout/index.ts
create mode 100644 desktop/angular/src/app/shared/network-scout/network-scout.html
create mode 100644 desktop/angular/src/app/shared/network-scout/network-scout.scss
create mode 100644 desktop/angular/src/app/shared/network-scout/network-scout.ts
create mode 100644 desktop/angular/src/app/shared/notification-list/index.ts
create mode 100644 desktop/angular/src/app/shared/notification-list/notification-list.component.html
create mode 100644 desktop/angular/src/app/shared/notification-list/notification-list.component.scss
create mode 100644 desktop/angular/src/app/shared/notification-list/notification-list.component.ts
create mode 100644 desktop/angular/src/app/shared/notification/notification.html
create mode 100644 desktop/angular/src/app/shared/notification/notification.scss
create mode 100644 desktop/angular/src/app/shared/notification/notification.ts
create mode 100644 desktop/angular/src/app/shared/pipes/bytes.pipe.ts
create mode 100644 desktop/angular/src/app/shared/pipes/common-pipes.module.ts
create mode 100644 desktop/angular/src/app/shared/pipes/duration.pipe.ts
create mode 100644 desktop/angular/src/app/shared/pipes/index.ts
create mode 100644 desktop/angular/src/app/shared/pipes/round.pipe.ts
create mode 100644 desktop/angular/src/app/shared/pipes/time-ago.pipe.ts
create mode 100644 desktop/angular/src/app/shared/pipes/to-profile.pipe.ts
create mode 100644 desktop/angular/src/app/shared/pipes/to-seconds.pipe.ts
create mode 100644 desktop/angular/src/app/shared/process-details-dialog/index.ts
create mode 100644 desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.html
create mode 100644 desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.scss
create mode 100644 desktop/angular/src/app/shared/process-details-dialog/process-details-dialog.ts
create mode 100644 desktop/angular/src/app/shared/prompt-list/index.ts
create mode 100644 desktop/angular/src/app/shared/prompt-list/prompt-list.component.html
create mode 100644 desktop/angular/src/app/shared/prompt-list/prompt-list.component.scss
create mode 100644 desktop/angular/src/app/shared/prompt-list/prompt-list.component.ts
create mode 100644 desktop/angular/src/app/shared/security-lock/index.ts
create mode 100644 desktop/angular/src/app/shared/security-lock/security-lock.html
create mode 100644 desktop/angular/src/app/shared/security-lock/security-lock.scss
create mode 100644 desktop/angular/src/app/shared/security-lock/security-lock.ts
create mode 100644 desktop/angular/src/app/shared/spn-account-details/index.ts
create mode 100644 desktop/angular/src/app/shared/spn-account-details/spn-account-details.html
create mode 100644 desktop/angular/src/app/shared/spn-account-details/spn-account-details.scss
create mode 100644 desktop/angular/src/app/shared/spn-account-details/spn-account-details.ts
create mode 100644 desktop/angular/src/app/shared/spn-login/index.ts
create mode 100644 desktop/angular/src/app/shared/spn-login/spn-login.html
create mode 100644 desktop/angular/src/app/shared/spn-login/spn-login.scss
create mode 100644 desktop/angular/src/app/shared/spn-login/spn-login.ts
create mode 100644 desktop/angular/src/app/shared/spn-network-status/index.ts
create mode 100644 desktop/angular/src/app/shared/spn-network-status/spn-network-status.html
create mode 100644 desktop/angular/src/app/shared/spn-network-status/spn-network-status.scss
create mode 100644 desktop/angular/src/app/shared/spn-network-status/spn-network-status.ts
create mode 100644 desktop/angular/src/app/shared/spn-status/index.ts
create mode 100644 desktop/angular/src/app/shared/spn-status/spn-status.html
create mode 100644 desktop/angular/src/app/shared/spn-status/spn-status.ts
create mode 100644 desktop/angular/src/app/shared/status-pilot/index.ts
create mode 100644 desktop/angular/src/app/shared/status-pilot/pilot-widget.html
create mode 100644 desktop/angular/src/app/shared/status-pilot/pilot-widget.scss
create mode 100644 desktop/angular/src/app/shared/status-pilot/pilot-widget.ts
create mode 100644 desktop/angular/src/app/shared/text-placeholder/index.ts
create mode 100644 desktop/angular/src/app/shared/text-placeholder/placeholder.scss
create mode 100644 desktop/angular/src/app/shared/text-placeholder/placeholder.ts
create mode 100644 desktop/angular/src/app/shared/utils.ts
create mode 120000 desktop/angular/src/assets
create mode 100644 desktop/angular/src/electron-app.d.ts
create mode 100644 desktop/angular/src/environments/environment.prod.ts
create mode 100644 desktop/angular/src/environments/environment.ts
create mode 100644 desktop/angular/src/i18n/helptexts.yaml
create mode 100644 desktop/angular/src/i18n/helptexts.yaml.d.ts
create mode 100644 desktop/angular/src/index.html
create mode 100644 desktop/angular/src/main.ts
create mode 100644 desktop/angular/src/polyfills.ts
create mode 100644 desktop/angular/src/styles.scss
create mode 100644 desktop/angular/src/test.ts
create mode 100644 desktop/angular/src/theme.less
create mode 100644 desktop/angular/src/theme/_breadcrumbs.scss
create mode 100644 desktop/angular/src/theme/_button.scss
create mode 100644 desktop/angular/src/theme/_card.scss
create mode 100644 desktop/angular/src/theme/_colors.scss
create mode 100644 desktop/angular/src/theme/_dialog.scss
create mode 100644 desktop/angular/src/theme/_drag-n-drop.scss
create mode 100644 desktop/angular/src/theme/_inputs.scss
create mode 100644 desktop/angular/src/theme/_markdown.scss
create mode 100644 desktop/angular/src/theme/_pill.scss
create mode 100644 desktop/angular/src/theme/_scroll.scss
create mode 100644 desktop/angular/src/theme/_search.scss
create mode 100644 desktop/angular/src/theme/_table.scss
create mode 100644 desktop/angular/src/theme/_tailwind.scss
create mode 100644 desktop/angular/src/theme/_trust-level.scss
create mode 100644 desktop/angular/src/theme/_typography.scss
create mode 100644 desktop/angular/src/theme/_verdict.scss
create mode 100644 desktop/angular/src/theme/mixins/_pill.scss
create mode 100644 desktop/angular/tailwind.config.js
create mode 100644 desktop/angular/tsconfig.app.json
create mode 100644 desktop/angular/tsconfig.json
create mode 100644 desktop/angular/tsconfig.spec.json
create mode 100644 desktop/angular/tslint.json
diff --git a/.earthlyignore b/.earthlyignore
index 9b694cb7..37c45b4d 100644
--- a/.earthlyignore
+++ b/.earthlyignore
@@ -7,4 +7,9 @@ node_modules/
desktop/angular/node_modules
desktop/angular/dist
desktop/angular/dist-lib
-desktop/angular/dist-extension
\ No newline at end of file
+desktop/angular/dist-extension
+desktop/angular/.angular
+
+# Assets are ignored here because the symlink wouldn't work in
+# the buildkit container so we copy the assets directly in Earthfile.
+desktop/angular/assets
\ No newline at end of file
diff --git a/Earthfile b/Earthfile
index 1ca151dc..3638f0d6 100644
--- a/Earthfile
+++ b/Earthfile
@@ -52,7 +52,7 @@ build-go:
ARG GOOS=linux
ARG GOARCH=amd64
ARG GOARM
- ARG CMDS=portmaster-start portmaster-core hub
+ ARG CMDS=portmaster-start portmaster-core hub notifier
CACHE --sharing shared "$GOCACHE"
CACHE --sharing shared "$GOMODCACHE"
@@ -112,7 +112,7 @@ test-go-all-platforms:
BUILD +test-go --GOARCH=amd64 --GOOS=windows
BUILD +test-go --GOARCH=arm64 --GOOS=windows
-# Builds portmaster-start and portmaster-core for all supported platforms
+# Builds portmaster-start, portmaster-core, hub and notifier for all supported platforms
build-go-release:
# Linux platforms:
BUILD +build-go --GOARCH=amd64 --GOOS=linux
@@ -131,46 +131,61 @@ build-utils:
BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=linux
BUILD +build-go --CMDS="" --GOARCH=amd64 --GOOS=windows
-# Prepares the angular project
+# Prepares the angular project by installing dependencies
angular-deps:
FROM node:${node_version}
WORKDIR /app/ui
RUN apt update && apt install zip
- CACHE --sharing shared "/app/ui/node_modules"
-
COPY desktop/angular/package.json .
COPY desktop/angular/package-lock.json .
+ COPY assets/ ./assets
+
RUN npm install
-
+# Copies the UI folder into the working container
+# and builds the shared libraries in the specified configuration (production or development)
angular-base:
FROM +angular-deps
+ ARG configuration="production"
COPY desktop/angular/ .
-# Build the Portmaster UI (angular) in release mode
+ IF [ "${configuration}" = "production" ]
+ RUN npm run build-libs
+ ELSE
+ RUN npm run build-libs:dev
+ END
+
+# Build an angualr project, zip it and save artifacts locally
+angular-project:
+ ARG --required project
+ ARG --required dist
+ ARG configuration="production"
+ ARG baseHref="/"
+
+ FROM +angular-base --configuration="${configuration}"
+
+ IF [ "${configuration}" = "production" ]
+ ENV NODE_ENV="production"
+ END
+
+ RUN ./node_modules/.bin/ng build --configuration ${configuration} --base-href ${baseHref} "${project}"
+
+ RUN zip -r "./${project}.zip" "${dist}"
+ SAVE ARTIFACT "./${project}.zip" AS LOCAL ${outputDir}/${project}.zip
+ SAVE ARTIFACT "./dist" AS LOCAL ${outputDir}/${project}
+
+# Build the angular projects (portmaster-UI and tauri-builtin) in production mode
angular-release:
- FROM +angular-base
+ BUILD +angular-project --project=portmaster --dist=./dist --configuration=production --baseHref=/ui/modules/portmaster
+ BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=production --baseHref="/"
- CACHE --sharing shared "/app/ui/node_modules"
-
- RUN npm run build
- RUN zip -r ./angular.zip ./dist
- SAVE ARTIFACT "./angular.zip" AS LOCAL ${outputDir}/angular.zip
- SAVE ARTIFACT "./dist" AS LOCAL ${outputDir}/angular
-
-
-# Build the Portmaster UI (angular) in dev mode
+# Build the angular projects (portmaster-UI and tauri-builtin) in dev mode
angular-dev:
- FROM +angular-base
-
- CACHE --sharing shared "/app/ui/node_modules"
-
- RUN npm run build:dev
- SAVE ARTIFACT ./dist AS LOCAL ${outputDir}/angular
-
+ BUILD +angular-project --project=portmaster --dist=./dist --configuration=development --baseHref=/ui/modules/portmaster
+ BUILD +angular-project --project=tauri-builtin --dist=./dist/tauri-builtin --configuration=development --baseHref="/"
release:
BUILD +build-go-release
diff --git a/assets/fonts/Roboto-300/LICENSE.txt b/assets/fonts/Roboto-300/LICENSE.txt
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/assets/fonts/Roboto-300/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/assets/fonts/Roboto-300/Roboto-300.eot b/assets/fonts/Roboto-300/Roboto-300.eot
new file mode 100644
index 0000000000000000000000000000000000000000..826acfda9102ca6aba858813c72fa34b6968da36
GIT binary patch
literal 16205
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
GcK$C}BGQQf
literal 0
HcmV?d00001
diff --git a/assets/fonts/Roboto-300/Roboto-300.svg b/assets/fonts/Roboto-300/Roboto-300.svg
new file mode 100644
index 00000000..52b28327
--- /dev/null
+++ b/assets/fonts/Roboto-300/Roboto-300.svg
@@ -0,0 +1,314 @@
+
+
+
diff --git a/assets/fonts/Roboto-300/Roboto-300.ttf b/assets/fonts/Roboto-300/Roboto-300.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..66bc5ab8e299e9948a6fce6b7867cc638bc703c7
GIT binary patch
literal 32664
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