wip: migrate to mono-repo. SPN has already been moved to spn/

This commit is contained in:
Patrick Pacher
2024-03-15 11:55:13 +01:00
parent b30fd00ccf
commit 8579430db9
577 changed files with 35981 additions and 818 deletions

202
spn/hub/database.go Normal file
View File

@@ -0,0 +1,202 @@
package hub
import (
"errors"
"fmt"
"sync"
"time"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/iterator"
"github.com/safing/portbase/database/query"
"github.com/safing/portbase/database/record"
)
var (
db = database.NewInterface(&database.Options{
Local: true,
Internal: true,
})
getFromNavigator func(mapName, hubID string) *Hub
)
// MakeHubDBKey makes a hub db key.
func MakeHubDBKey(mapName, hubID string) string {
return fmt.Sprintf("cache:spn/hubs/%s/%s", mapName, hubID)
}
// MakeHubMsgDBKey makes a hub msg db key.
func MakeHubMsgDBKey(mapName string, msgType MsgType, hubID string) string {
return fmt.Sprintf("cache:spn/msgs/%s/%s/%s", mapName, msgType, hubID)
}
// SetNavigatorAccess sets a shortcut function to access hubs from the navigator instead of having go through the database.
// This also reduces the number of object in RAM and better caches parsed attributes.
func SetNavigatorAccess(fn func(mapName, hubID string) *Hub) {
if getFromNavigator == nil {
getFromNavigator = fn
}
}
// GetHub get a Hub from the database - or the navigator, if configured.
func GetHub(mapName string, hubID string) (*Hub, error) {
if getFromNavigator != nil {
hub := getFromNavigator(mapName, hubID)
if hub != nil {
return hub, nil
}
}
return GetHubByKey(MakeHubDBKey(mapName, hubID))
}
// GetHubByKey returns a hub by its raw DB key.
func GetHubByKey(key string) (*Hub, error) {
r, err := db.Get(key)
if err != nil {
return nil, err
}
hub, err := EnsureHub(r)
if err != nil {
return nil, err
}
return hub, nil
}
// EnsureHub makes sure a database record is a Hub.
func EnsureHub(r record.Record) (*Hub, error) {
// unwrap
if r.IsWrapped() {
// only allocate a new struct, if we need it
newHub := &Hub{}
err := record.Unwrap(r, newHub)
if err != nil {
return nil, err
}
newHub = prepHub(newHub)
// Fully validate when getting from database.
if err := newHub.Info.validateFormatting(); err != nil {
return nil, fmt.Errorf("announcement failed format validation: %w", err)
}
if err := newHub.Status.validateFormatting(); err != nil {
return nil, fmt.Errorf("status failed format validation: %w", err)
}
if err := newHub.Info.prepare(false); err != nil {
return nil, fmt.Errorf("failed to prepare announcement: %w", err)
}
return newHub, nil
}
// or adjust type
newHub, ok := r.(*Hub)
if !ok {
return nil, fmt.Errorf("record not of type *Hub, but %T", r)
}
newHub = prepHub(newHub)
// Prepare only when already parsed.
if err := newHub.Info.prepare(false); err != nil {
return nil, fmt.Errorf("failed to prepare announcement: %w", err)
}
// ensure status
return newHub, nil
}
func prepHub(h *Hub) *Hub {
if h.Status == nil {
h.Status = &Status{}
}
h.Measurements = getSharedMeasurements(h.ID, h.Measurements)
return h
}
// Save saves to Hub to the correct scope in the database.
func (h *Hub) Save() error {
if !h.KeyIsSet() {
h.SetKey(MakeHubDBKey(h.Map, h.ID))
}
return db.Put(h)
}
// RemoveHubAndMsgs deletes a Hub and it's saved messages from the database.
func RemoveHubAndMsgs(mapName string, hubID string) (err error) {
err = db.Delete(MakeHubDBKey(mapName, hubID))
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to delete main hub entry: %w", err)
}
err = db.Delete(MakeHubMsgDBKey(mapName, MsgTypeAnnouncement, hubID))
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to delete hub announcement data: %w", err)
}
err = db.Delete(MakeHubMsgDBKey(mapName, MsgTypeStatus, hubID))
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("failed to delete hub status data: %w", err)
}
return nil
}
// HubMsg stores raw Hub messages.
type HubMsg struct { //nolint:golint
record.Base
sync.Mutex
ID string
Map string
Type MsgType
Data []byte
Received int64
}
// SaveHubMsg saves a raw (and signed) message received by another Hub.
func SaveHubMsg(id string, mapName string, msgType MsgType, data []byte) error {
// create wrapper record
msg := &HubMsg{
ID: id,
Map: mapName,
Type: msgType,
Data: data,
Received: time.Now().Unix(),
}
// set key
msg.SetKey(MakeHubMsgDBKey(msg.Map, msg.Type, msg.ID))
// save
return db.PutNew(msg)
}
// QueryRawGossipMsgs queries the database for raw gossip messages.
func QueryRawGossipMsgs(mapName string, msgType MsgType) (it *iterator.Iterator, err error) {
it, err = db.Query(query.New(MakeHubMsgDBKey(mapName, msgType, "")))
return
}
// EnsureHubMsg makes sure a database record is a HubMsg.
func EnsureHubMsg(r record.Record) (*HubMsg, error) {
// unwrap
if r.IsWrapped() {
// only allocate a new struct, if we need it
newHubMsg := &HubMsg{}
err := record.Unwrap(r, newHubMsg)
if err != nil {
return nil, err
}
return newHubMsg, nil
}
// or adjust type
newHubMsg, ok := r.(*HubMsg)
if !ok {
return nil, fmt.Errorf("record not of type *Hub, but %T", r)
}
return newHubMsg, nil
}

21
spn/hub/errors.go Normal file
View File

@@ -0,0 +1,21 @@
package hub
import "errors"
var (
// ErrMissingInfo signifies that the hub is missing the HubAnnouncement.
ErrMissingInfo = errors.New("hub has no announcement")
// ErrMissingTransports signifies that the hub announcement did not specify any transports.
ErrMissingTransports = errors.New("hub announcement has no transports")
// ErrMissingIPs signifies that the hub announcement did not specify any IPs,
// or none of the IPs is supported by the client.
ErrMissingIPs = errors.New("hub announcement has no (supported) IPs")
// ErrTemporaryValidationError is returned when a validation error might be temporary.
ErrTemporaryValidationError = errors.New("temporary validation error")
// ErrOldData is returned when received data is outdated.
ErrOldData = errors.New("")
)

69
spn/hub/format.go Normal file
View File

@@ -0,0 +1,69 @@
package hub
import (
"fmt"
"net"
"regexp"
"github.com/safing/portmaster/service/network/netutils"
)
// BaselineCharset defines the permitted characters.
var BaselineCharset = regexp.MustCompile(
// Start of charset selection.
`^[` +
// Printable ASCII (character code 32-127), excluding common control characters of different languages: "$%&';<>\` and DELETE.
` !#()*+,\-\./0-9:=?@A-Z[\]^_a-z{|}~` +
// Only latin characters from extended ASCII (character code 128-255).
`ŠŒŽšœžŸ¡¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ` +
// End of charset selection.
`]*$`,
)
func checkStringFormat(fieldName, value string, maxLength int) error {
switch {
case len(value) > maxLength:
return fmt.Errorf("field %s with length of %d exceeds max length of %d", fieldName, len(value), maxLength)
case !BaselineCharset.MatchString(value):
return fmt.Errorf("field %s contains characters not permitted by baseline validation", fieldName)
default:
return nil
}
}
func checkStringSliceFormat(fieldName string, value []string, maxLength, maxStringLength int) error { //nolint:unparam
if len(value) > maxLength {
return fmt.Errorf("field %s with array/slice length of %d exceeds max length of %d", fieldName, len(value), maxLength)
}
for _, s := range value {
if err := checkStringFormat(fieldName, s, maxStringLength); err != nil {
return err
}
}
return nil
}
func checkByteSliceFormat(fieldName string, value []byte, maxLength int) error {
switch {
case len(value) > maxLength:
return fmt.Errorf("field %s with length of %d exceeds max length of %d", fieldName, len(value), maxLength)
default:
return nil
}
}
func checkIPFormat(fieldName string, value net.IP) error {
// Check if there is an IP address.
if value == nil {
return nil
}
switch {
case len(value) != 4 && len(value) != 16:
return fmt.Errorf("field %s has an invalid length of %d for an IP address", fieldName, len(value))
case netutils.GetIPScope(value) == netutils.Invalid:
return fmt.Errorf("field %s holds an invalid IP address: %s", fieldName, value)
default:
return nil
}
}

81
spn/hub/format_test.go Normal file
View File

@@ -0,0 +1,81 @@
package hub
import (
"fmt"
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCheckStringFormat(t *testing.T) {
t.Parallel()
testSet := map[string]bool{
// Printable ASCII (character code 32-127)
" ": true, "!": true, `"`: false, "#": true, "$": false, "%": false, "&": false, "'": false,
"(": true, ")": true, "*": true, "+": true, ",": true, "-": true, ".": true, "/": true,
"0": true, "1": true, "2": true, "3": true, "4": true, "5": true, "6": true, "7": true,
"8": true, "9": true, ":": true, ";": false, "<": false, "=": true, ">": false, "?": true,
"@": true, "A": true, "B": true, "C": true, "D": true, "E": true, "F": true, "G": true,
"H": true, "I": true, "J": true, "K": true, "L": true, "M": true, "N": true, "O": true,
"P": true, "Q": true, "R": true, "S": true, "T": true, "U": true, "V": true, "W": true,
"X": true, "Y": true, "Z": true, "[": true, `\`: false, "]": true, "^": true, "_": true,
"`": false, "a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true,
"h": true, "i": true, "j": true, "k": true, "l": true, "m": true, "n": true, "o": true,
"p": true, "q": true, "r": true, "s": true, "t": true, "u": true, "v": true, "w": true,
"x": true, "y": true, "z": true, "{": true, "|": true, "}": true, "~": true,
// Not testing for DELETE character.
// Extended ASCII (character code 128-255)
"€": false, "": false, "ƒ": false, "„": false, "…": false, "†": false, "‡": false, "ˆ": false,
"‰": false, "Š": true, "": false, "Œ": true, "Ž": true, "": false, "": false, "“": false,
"”": false, "•": false, "": false, "—": false, "˜": false, "™": false, "š": true, "": false,
"œ": true, "ž": true, "Ÿ": true, "¡": true, "¢": false, "£": false, "¤": false, "¥": false,
"¦": false, "§": false, "¨": false, "©": false, "ª": false, "«": false, "¬": false, "®": false,
"¯": false, "°": false, "±": false, "²": false, "³": false, "´": false, "µ": false, "¶": false,
"·": false, "¸": false, "¹": false, "º": false, "»": false, "¼": false, "½": false, "¾": false,
"¿": true, "À": true, "Á": true, "Â": true, "Ã": true, "Ä": true, "Å": true, "Æ": true,
"Ç": true, "È": true, "É": true, "Ê": true, "Ë": true, "Ì": true, "Í": true, "Î": true,
"Ï": true, "Ð": true, "Ñ": true, "Ò": true, "Ó": true, "Ô": true, "Õ": true, "Ö": true,
"×": false, "Ø": true, "Ù": true, "Ú": true, "Û": true, "Ü": true, "Ý": true, "Þ": true,
"ß": true, "à": true, "á": true, "â": true, "ã": true, "ä": true, "å": true, "æ": true,
"ç": true, "è": true, "é": true, "ê": true, "ë": true, "ì": true, "í": true, "î": true,
"ï": true, "ð": true, "ñ": true, "ò": true, "ó": true, "ô": true, "õ": true, "ö": true,
"÷": false, "ø": true, "ù": true, "ú": true, "û": true, "ü": true, "ý": true, "þ": true,
"ÿ": true,
}
for testCharacter, isPermitted := range testSet {
if isPermitted {
assert.NoError(t, checkStringFormat(fmt.Sprintf("test character %q", testCharacter), testCharacter, 3))
} else {
assert.Error(t, checkStringFormat(fmt.Sprintf("test character %q", testCharacter), testCharacter, 3))
}
}
}
func TestCheckIPFormat(t *testing.T) {
t.Parallel()
// IPv4
assert.NoError(t, checkIPFormat("test IP 1.1.1.1", net.IPv4(1, 1, 1, 1)))
assert.NoError(t, checkIPFormat("test IP 192.168.1.1", net.IPv4(192, 168, 1, 1)))
assert.Error(t, checkIPFormat("test IP 255.0.0.1", net.IPv4(255, 0, 0, 1)))
// IPv6
assert.NoError(t, checkIPFormat("test IP ::1", net.ParseIP("::1")))
assert.NoError(t, checkIPFormat("test IP 2606:4700:4700::1111", net.ParseIP("2606:4700:4700::1111")))
// Invalid
assert.Error(t, checkIPFormat("test IP with length 3", net.IP([]byte{0, 0, 0})))
assert.Error(t, checkIPFormat("test IP with length 5", net.IP([]byte{0, 0, 0, 0, 0})))
assert.Error(t, checkIPFormat(
"test IP with length 15",
net.IP([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}),
))
assert.Error(t, checkIPFormat(
"test IP with length 17",
net.IP([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}),
))
}

435
spn/hub/hub.go Normal file
View File

@@ -0,0 +1,435 @@
package hub
import (
"fmt"
"net"
"sync"
"time"
"golang.org/x/exp/slices"
"github.com/safing/jess"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/profile/endpoints"
)
// Scope is the network scope a Hub can be in.
type Scope uint8
const (
// ScopeInvalid defines an invalid scope.
ScopeInvalid Scope = 0
// ScopeLocal identifies local Hubs.
ScopeLocal Scope = 1
// ScopePublic identifies public Hubs.
ScopePublic Scope = 2
// ScopeTest identifies Hubs for testing.
ScopeTest Scope = 0xFF
)
const (
obsoleteValidAfter = 30 * 24 * time.Hour
obsoleteInvalidAfter = 7 * 24 * time.Hour
)
// MsgType defines the message type.
type MsgType string
// Message Types.
const (
MsgTypeAnnouncement = "announcement"
MsgTypeStatus = "status"
)
// Hub represents a network node in the SPN.
type Hub struct { //nolint:maligned
sync.Mutex
record.Base
ID string
PublicKey *jess.Signet
Map string
Info *Announcement
Status *Status
Measurements *Measurements
measurementsInitialized bool
FirstSeen time.Time
VerifiedIPs bool
InvalidInfo bool
InvalidStatus bool
}
// Announcement is the main message type to publish Hub Information. This only changes if updated manually.
type Announcement struct {
// Primary Key
// hash of public key
// must be checked if it matches the public key
ID string `cbor:"i"` // via jess.LabeledHash
// PublicKey *jess.Signet
// PublicKey // if not part of signature
// Signature *jess.Letter
Timestamp int64 `cbor:"t"` // Unix timestamp in seconds
// Node Information
Name string `cbor:"n"` // name of the node
Group string `cbor:"g,omitempty" json:",omitempty"` // person or organisation, who is in control of the node (should be same for all nodes of this person or organisation)
ContactAddress string `cbor:"ca,omitempty" json:",omitempty"` // contact possibility (recommended, but optional)
ContactService string `cbor:"cs,omitempty" json:",omitempty"` // type of service of the contact address, if not email
// currently unused, but collected for later use
Hosters []string `cbor:"ho,omitempty" json:",omitempty"` // hoster supply chain (reseller, hosting provider, datacenter operator, ...)
Datacenter string `cbor:"dc,omitempty" json:",omitempty"` // datacenter will be bullshit checked
// Format: CC-COMPANY-INTERNALCODE
// Eg: DE-Hetzner-FSN1-DC5
// Network Location and Access
// If node is behind NAT (or similar), IP addresses must be configured
IPv4 net.IP `cbor:"ip4,omitempty" json:",omitempty"` // must be global and accessible
IPv6 net.IP `cbor:"ip6,omitempty" json:",omitempty"` // must be global and accessible
Transports []string `cbor:"tp,omitempty" json:",omitempty"`
// {
// "spn:17",
// "smtp:25", // also support "smtp://:25
// "smtp:587",
// "imap:143",
// "http:80",
// "http://example.com:80", // HTTP (based): use full path for request
// "https:443",
// "ws:80",
// "wss://example.com:443/spn",
// } // protocols with metadata
parsedTransports []*Transport
// Policies - default permit
Entry []string `cbor:"pi,omitempty" json:",omitempty"`
entryPolicy endpoints.Endpoints
// {"+ ", "- *"}
Exit []string `cbor:"po,omitempty" json:",omitempty"`
exitPolicy endpoints.Endpoints
// {"- * TCP/25", "- US"}
// Flags holds flags that signify special states.
Flags []string `cbor:"f,omitempty" json:",omitempty"`
}
// Copy returns a deep copy of the Announcement.
func (a *Announcement) Copy() *Announcement {
return &Announcement{
ID: a.ID,
Timestamp: a.Timestamp,
Name: a.Name,
ContactAddress: a.ContactAddress,
ContactService: a.ContactService,
Hosters: slices.Clone(a.Hosters),
Datacenter: a.Datacenter,
IPv4: a.IPv4,
IPv6: a.IPv6,
Transports: slices.Clone(a.Transports),
parsedTransports: slices.Clone(a.parsedTransports),
Entry: slices.Clone(a.Entry),
entryPolicy: slices.Clone(a.entryPolicy),
Exit: slices.Clone(a.Exit),
exitPolicy: slices.Clone(a.exitPolicy),
Flags: slices.Clone(a.Flags),
}
}
// GetInfo returns the hub info.
func (h *Hub) GetInfo() *Announcement {
h.Lock()
defer h.Unlock()
return h.Info
}
// GetStatus returns the hub status.
func (h *Hub) GetStatus() *Status {
h.Lock()
defer h.Unlock()
return h.Status
}
// GetMeasurements returns the hub measurements.
// This method should always be used instead of direct access.
func (h *Hub) GetMeasurements() *Measurements {
h.Lock()
defer h.Unlock()
return h.GetMeasurementsWithLockedHub()
}
// GetMeasurementsWithLockedHub returns the hub measurements.
// The caller must hold the lock to Hub.
// This method should always be used instead of direct access.
func (h *Hub) GetMeasurementsWithLockedHub() *Measurements {
if !h.measurementsInitialized {
h.Measurements = getSharedMeasurements(h.ID, h.Measurements)
h.Measurements.check()
h.measurementsInitialized = true
}
return h.Measurements
}
// Verified return whether the Hub has been verified.
func (h *Hub) Verified() bool {
h.Lock()
defer h.Unlock()
return h.VerifiedIPs
}
// String returns a human-readable representation of the Hub.
func (h *Hub) String() string {
h.Lock()
defer h.Unlock()
return "<Hub " + h.getName() + ">"
}
// StringWithoutLocking returns a human-readable representation of the Hub without locking it.
func (h *Hub) StringWithoutLocking() string {
return "<Hub " + h.getName() + ">"
}
// Name returns a human-readable version of a Hub's name. This name will likely consist of two parts: the given name and the ending of the ID to make it unique.
func (h *Hub) Name() string {
h.Lock()
defer h.Unlock()
return h.getName()
}
func (h *Hub) getName() string {
// Check for a short ID that is sometimes used for testing.
if len(h.ID) < 8 {
return h.ID
}
shortenedID := h.ID[len(h.ID)-8:len(h.ID)-4] +
"-" +
h.ID[len(h.ID)-4:]
// Be more careful, as the Hub name is user input.
switch {
case h.Info.Name == "":
return shortenedID
case len(h.Info.Name) > 16:
return h.Info.Name[:16] + " " + shortenedID
default:
return h.Info.Name + " " + shortenedID
}
}
// Obsolete returns if the Hub is obsolete and may be deleted.
func (h *Hub) Obsolete() bool {
h.Lock()
defer h.Unlock()
// Check if Hub is valid.
var valid bool
switch {
case h.InvalidInfo:
case h.InvalidStatus:
case h.HasFlag(FlagOffline):
// Treat offline as invalid.
default:
valid = true
}
// Check when Hub was last seen.
lastSeen := h.FirstSeen
if h.Status.Timestamp != 0 {
lastSeen = time.Unix(h.Status.Timestamp, 0)
}
// Check if Hub is obsolete.
if valid {
return time.Now().Add(-obsoleteValidAfter).After(lastSeen)
}
return time.Now().Add(-obsoleteInvalidAfter).After(lastSeen)
}
// HasFlag returns whether the Announcement or Status has the given flag set.
func (h *Hub) HasFlag(flagName string) bool {
switch {
case h.Status != nil && slices.Contains[[]string, string](h.Status.Flags, flagName):
return true
case h.Info != nil && slices.Contains[[]string, string](h.Info.Flags, flagName):
return true
}
return false
}
// Equal returns whether the given Announcements are equal.
func (a *Announcement) Equal(b *Announcement) bool {
switch {
case a == nil || b == nil:
return false
case a.ID != b.ID:
return false
case a.Timestamp != b.Timestamp:
return false
case a.Name != b.Name:
return false
case a.ContactAddress != b.ContactAddress:
return false
case a.ContactService != b.ContactService:
return false
case !equalStringSlice(a.Hosters, b.Hosters):
return false
case a.Datacenter != b.Datacenter:
return false
case !a.IPv4.Equal(b.IPv4):
return false
case !a.IPv6.Equal(b.IPv6):
return false
case !equalStringSlice(a.Transports, b.Transports):
return false
case !equalStringSlice(a.Entry, b.Entry):
return false
case !equalStringSlice(a.Exit, b.Exit):
return false
case !equalStringSlice(a.Flags, b.Flags):
return false
default:
return true
}
}
// validateFormatting check if all values conform to the basic format.
func (a *Announcement) validateFormatting() error {
if err := checkStringFormat("ID", a.ID, 255); err != nil {
return err
}
if err := checkStringFormat("Name", a.Name, 32); err != nil {
return err
}
if err := checkStringFormat("Group", a.Group, 32); err != nil {
return err
}
if err := checkStringFormat("ContactAddress", a.ContactAddress, 255); err != nil {
return err
}
if err := checkStringFormat("ContactService", a.ContactService, 255); err != nil {
return err
}
if err := checkStringSliceFormat("Hosters", a.Hosters, 255, 255); err != nil {
return err
}
if err := checkStringFormat("Datacenter", a.Datacenter, 255); err != nil {
return err
}
if err := checkIPFormat("IPv4", a.IPv4); err != nil {
return err
}
if err := checkIPFormat("IPv6", a.IPv6); err != nil {
return err
}
if err := checkStringSliceFormat("Transports", a.Transports, 255, 255); err != nil {
return err
}
if err := checkStringSliceFormat("Entry", a.Entry, 255, 255); err != nil {
return err
}
if err := checkStringSliceFormat("Exit", a.Exit, 255, 255); err != nil {
return err
}
if err := checkStringSliceFormat("Flags", a.Flags, 16, 32); err != nil {
return err
}
return nil
}
// Prepare prepares the announcement by parsing policies and transports.
// If fields are already parsed, they will only be parsed again, when force is set to true.
func (a *Announcement) prepare(force bool) error {
var err error
// Parse policies.
if len(a.entryPolicy) == 0 || force {
if a.entryPolicy, err = endpoints.ParseEndpoints(a.Entry); err != nil {
return fmt.Errorf("failed to parse entry policy: %w", err)
}
}
if len(a.exitPolicy) == 0 || force {
if a.exitPolicy, err = endpoints.ParseEndpoints(a.Exit); err != nil {
return fmt.Errorf("failed to parse exit policy: %w", err)
}
}
// Parse transports.
if len(a.parsedTransports) == 0 || force {
parsed, errs := ParseTransports(a.Transports)
// Log parsing warnings.
for _, err := range errs {
log.Warningf("hub: Hub %s (%s) has configured an %s", a.Name, a.ID, err)
}
// Check if there are any valid transports.
if len(parsed) == 0 {
return ErrMissingTransports
}
a.parsedTransports = parsed
}
return nil
}
// EntryPolicy returns the Hub's entry policy.
func (a *Announcement) EntryPolicy() endpoints.Endpoints {
return a.entryPolicy
}
// ExitPolicy returns the Hub's exit policy.
func (a *Announcement) ExitPolicy() endpoints.Endpoints {
return a.exitPolicy
}
// ParsedTransports returns the Hub's parsed transports.
func (a *Announcement) ParsedTransports() []*Transport {
return a.parsedTransports
}
// HasFlag returns whether the Announcement has the given flag set.
func (a *Announcement) HasFlag(flagName string) bool {
return slices.Contains[[]string, string](a.Flags, flagName)
}
// String returns the string representation of the scope.
func (s Scope) String() string {
switch s {
case ScopeInvalid:
return "invalid"
case ScopeLocal:
return "local"
case ScopePublic:
return "public"
case ScopeTest:
return "test"
default:
return "unknown"
}
}
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true
}

79
spn/hub/hub_test.go Normal file
View File

@@ -0,0 +1,79 @@
package hub
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/safing/portbase/modules"
_ "github.com/safing/portmaster/service/core/base"
"github.com/safing/portmaster/service/core/pmtesting"
)
func TestMain(m *testing.M) {
// TODO: We need the database module, so maybe set up a module for this package.
module := modules.Register("hub", nil, nil, nil, "base")
pmtesting.TestMain(m, module)
}
func TestEquality(t *testing.T) {
t.Parallel()
// empty match
a := &Announcement{}
assert.True(t, a.Equal(a), "should match itself") //nolint:gocritic // This is a test.
// full match
a = &Announcement{
ID: "a",
Timestamp: 1,
Name: "a",
ContactAddress: "a",
ContactService: "a",
Hosters: []string{"a", "b"},
Datacenter: "a",
IPv4: net.IPv4(1, 2, 3, 4),
IPv6: net.ParseIP("::1"),
Transports: []string{"a", "b"},
Entry: []string{"a", "b"},
Exit: []string{"a", "b"},
}
assert.True(t, a.Equal(a), "should match itself") //nolint:gocritic // This is a test.
// no match
b := &Announcement{ID: "b"}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Timestamp: 2}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Name: "b"}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{ContactAddress: "b"}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{ContactService: "b"}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Hosters: []string{"b", "c"}}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Datacenter: "b"}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{IPv4: net.IPv4(1, 2, 3, 5)}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{IPv6: net.ParseIP("::2")}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Transports: []string{"b", "c"}}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Entry: []string{"b", "c"}}
assert.False(t, a.Equal(b), "should not match")
b = &Announcement{Exit: []string{"b", "c"}}
assert.False(t, a.Equal(b), "should not match")
}
func TestStringify(t *testing.T) {
t.Parallel()
assert.Equal(t, "<Hub abcdefg>", (&Hub{ID: "abcdefg", Info: &Announcement{}}).String())
assert.Equal(t, "<Hub abcd-efgh>", (&Hub{ID: "abcdefgh", Info: &Announcement{}}).String())
assert.Equal(t, "<Hub bcde-fghi>", (&Hub{ID: "abcdefghi", Info: &Announcement{}}).String())
assert.Equal(t, "<Hub Franz bcde-fghi>", (&Hub{ID: "abcdefghi", Info: &Announcement{Name: "Franz"}}).String())
assert.Equal(t, "<Hub AProbablyAutoGen bcde-fghi>", (&Hub{ID: "abcdefghi", Info: &Announcement{Name: "AProbablyAutoGeneratedName"}}).String())
}

191
spn/hub/intel.go Normal file
View File

@@ -0,0 +1,191 @@
package hub
import (
"errors"
"fmt"
"net"
"github.com/ghodss/yaml"
"github.com/safing/jess/lhash"
"github.com/safing/portmaster/service/profile/endpoints"
)
// Intel holds a collection of various security related data collections on Hubs.
type Intel struct {
// BootstrapHubs is list of transports that also contain an IP and the Hub's ID.
BootstrapHubs []string
// Hubs holds intel regarding specific Hubs.
Hubs map[string]*HubIntel
// AdviseOnlyTrustedHubs advises to only use trusted Hubs regardless of intended purpose.
AdviseOnlyTrustedHubs bool
// AdviseOnlyTrustedHomeHubs advises to only use trusted Hubs for Home Hubs.
AdviseOnlyTrustedHomeHubs bool
// AdviseOnlyTrustedDestinationHubs advises to only use trusted Hubs for Destination Hubs.
AdviseOnlyTrustedDestinationHubs bool
// Hub Advisories advise on the usage of Hubs and take the form of Endpoint Lists that match on both IPv4 and IPv6 addresses and their related data.
// HubAdvisory always affects all Hubs.
HubAdvisory []string
// HomeHubAdvisory is only taken into account when selecting a Home Hub.
HomeHubAdvisory []string
// DestinationHubAdvisory is only taken into account when selecting a Destination Hub.
DestinationHubAdvisory []string
// Regions defines regions to assist network optimization.
Regions []*RegionConfig
// VirtualNetworks holds network configurations for virtual cloud networks.
VirtualNetworks []*VirtualNetworkConfig
parsed *ParsedIntel
}
// HubIntel holds Hub-related data.
type HubIntel struct { //nolint:golint
// Trusted specifies if the Hub is specially designated for more sensitive tasks, such as handling unencrypted traffic.
Trusted bool
// Discontinued specifies if the Hub has been discontinued and should be marked as offline and removed.
Discontinued bool
// VerifiedOwner holds the name of the verified owner / operator of the Hub.
VerifiedOwner string
// Override is used to override certain Hub information.
Override *InfoOverride
}
// RegionConfig holds the configuration of a region.
type RegionConfig struct {
// ID is the internal identifier of the region.
ID string
// Name is a human readable name of the region.
Name string
// MemberPolicy specifies a list for including members.
MemberPolicy []string
// RegionalMinLanes specifies how many lanes other regions should build
// to this region.
RegionalMinLanes int
// RegionalMinLanesPerHub specifies how many lanes other regions should
// build to this region, per Hub in this region.
// This value will usually be below one.
RegionalMinLanesPerHub float64
// RegionalMaxLanesOnHub specifies how many lanes from or to another region may be
// built on one Hub per region.
RegionalMaxLanesOnHub int
// SatelliteMinLanes specifies how many lanes satellites (Hubs without
// region) should build to this region.
SatelliteMinLanes int
// SatelliteMinLanesPerHub specifies how many lanes satellites (Hubs without
// region) should build to this region, per Hub in this region.
// This value will usually be below one.
SatelliteMinLanesPerHub float64
// InternalMinLanesOnHub specifies how many lanes every Hub should create
// within the region at minimum.
InternalMinLanesOnHub int
// InternalMaxHops specifies the max hop constraint for internally optimizing
// the region.
InternalMaxHops int
}
// VirtualNetworkConfig holds configuration of a virtual network that binds multiple Hubs together.
type VirtualNetworkConfig struct {
// Name is a human readable name of the virtual network.
Name string
// Force forces the use of the mapped IP addresses after the Hub's IPs have been verified.
Force bool
// Mapping maps Hub IDs to internal IP addresses.
Mapping map[string]net.IP
}
// ParsedIntel holds a collection of parsed intel data.
type ParsedIntel struct {
// HubAdvisory always affects all Hubs.
HubAdvisory endpoints.Endpoints
// HomeHubAdvisory is only taken into account when selecting a Home Hub.
HomeHubAdvisory endpoints.Endpoints
// DestinationHubAdvisory is only taken into account when selecting a Destination Hub.
DestinationHubAdvisory endpoints.Endpoints
}
// Parsed returns the collection of parsed intel data.
func (i *Intel) Parsed() *ParsedIntel {
return i.parsed
}
// ParseIntel parses Hub intelligence data.
func ParseIntel(data []byte) (*Intel, error) {
// Load data into struct.
intel := &Intel{}
err := yaml.Unmarshal(data, intel)
if err != nil {
return nil, fmt.Errorf("failed to parse data: %w", err)
}
// Parse all endpoint lists.
err = intel.ParseAdvisories()
if err != nil {
return nil, err
}
return intel, nil
}
// ParseAdvisories parses all advisory endpoint lists.
func (i *Intel) ParseAdvisories() (err error) {
i.parsed = &ParsedIntel{}
i.parsed.HubAdvisory, err = endpoints.ParseEndpoints(i.HubAdvisory)
if err != nil {
return fmt.Errorf("failed to parse HubAdvisory list: %w", err)
}
i.parsed.HomeHubAdvisory, err = endpoints.ParseEndpoints(i.HomeHubAdvisory)
if err != nil {
return fmt.Errorf("failed to parse HomeHubAdvisory list: %w", err)
}
i.parsed.DestinationHubAdvisory, err = endpoints.ParseEndpoints(i.DestinationHubAdvisory)
if err != nil {
return fmt.Errorf("failed to parse DestinationHubAdvisory list: %w", err)
}
return nil
}
// ParseBootstrapHub parses a bootstrap hub.
func ParseBootstrapHub(bootstrapTransport string) (t *Transport, hubID string, hubIP net.IP, err error) {
// Parse transport and check Hub ID.
t, err = ParseTransport(bootstrapTransport)
if err != nil {
return nil, "", nil, fmt.Errorf("failed to parse transport: %w", err)
}
if t.Option == "" {
return nil, "", nil, errors.New("missing hub ID in URL fragment")
}
if _, err := lhash.FromBase58(t.Option); err != nil {
return nil, "", nil, fmt.Errorf("hub ID is invalid: %w", err)
}
// Parse IP address from transport.
ip := net.ParseIP(t.Domain)
if ip == nil {
return nil, "", nil, errors.New("invalid IP address (domains are not supported for bootstrapping)")
}
// Clean up transport for hub info.
id := t.Option
t.Domain = ""
t.Option = ""
return t, id, ip, nil
}

17
spn/hub/intel_override.go Normal file
View File

@@ -0,0 +1,17 @@
package hub
import "github.com/safing/portmaster/service/intel/geoip"
// InfoOverride holds data to overide hub info information.
type InfoOverride struct {
// ContinentCode overrides the continent code of the geoip data.
ContinentCode string
// CountryCode overrides the country code of the geoip data.
CountryCode string
// Coordinates overrides the geo coordinates code of the geoip data.
Coordinates *geoip.Coordinates
// ASN overrides the Autonomous System Number of the geoip data.
ASN uint
// ASOrg overrides the Autonomous System Organization of the geoip data.
ASOrg string
}

231
spn/hub/measurements.go Normal file
View File

@@ -0,0 +1,231 @@
package hub
import (
"sync"
"time"
"github.com/tevino/abool"
)
// MaxCalculatedCost specifies the max calculated cost to be used for an unknown high cost.
const MaxCalculatedCost = 1000000
// Measurements holds various measurements relating to a Hub.
// Fields may not be accessed directly.
type Measurements struct {
sync.Mutex
// Latency designates the latency between these Hubs.
// It is specified in nanoseconds.
Latency time.Duration
// LatencyMeasuredAt holds when the latency was measured.
LatencyMeasuredAt time.Time
// Capacity designates the available bandwidth between these Hubs.
// It is specified in bit/s.
Capacity int
// CapacityMeasuredAt holds when the capacity measurement expires.
CapacityMeasuredAt time.Time
// CalculatedCost stores the calculated cost for direct access.
// It is not set automatically, but needs to be set when needed.
CalculatedCost float32
// GeoProximity stores an approximation of the geolocation proximity.
// The value is between 0 (other side of the world) and 100 (same location).
GeoProximity float32
// persisted holds whether the Measurements have been persisted to the
// database.
persisted *abool.AtomicBool
}
// NewMeasurements returns a new measurements struct.
func NewMeasurements() *Measurements {
m := &Measurements{
CalculatedCost: MaxCalculatedCost, // Push to back when sorting without data.
}
m.check()
return m
}
// Copy returns a copy of the measurements.
func (m *Measurements) Copy() *Measurements {
copied := &Measurements{
Latency: m.Latency,
LatencyMeasuredAt: m.LatencyMeasuredAt,
Capacity: m.Capacity,
CapacityMeasuredAt: m.CapacityMeasuredAt,
CalculatedCost: m.CalculatedCost,
}
copied.check()
return copied
}
// Check checks if the Measurements are properly initialized and ready to use.
func (m *Measurements) check() {
if m == nil {
return
}
m.Lock()
defer m.Unlock()
if m.persisted == nil {
m.persisted = abool.NewBool(true)
}
}
// IsPersisted return whether changes to the measurements have been persisted.
func (m *Measurements) IsPersisted() bool {
return m.persisted.IsSet()
}
// Valid returns whether there is a valid value .
func (m *Measurements) Valid() bool {
m.Lock()
defer m.Unlock()
switch {
case m.Latency == 0:
// Latency is not set.
case m.Capacity == 0:
// Capacity is not set.
case m.CalculatedCost == 0:
// CalculatedCost is not set.
case m.CalculatedCost == MaxCalculatedCost:
// CalculatedCost is set to static max value.
default:
return true
}
return false
}
// Expired returns whether any of the measurements has expired - calculated
// with the given TTL.
func (m *Measurements) Expired(ttl time.Duration) bool {
expiry := time.Now().Add(-ttl)
m.Lock()
defer m.Unlock()
switch {
case expiry.After(m.LatencyMeasuredAt):
return true
case expiry.After(m.CapacityMeasuredAt):
return true
default:
return false
}
}
// SetLatency sets the latency to the given value.
func (m *Measurements) SetLatency(latency time.Duration) {
m.Lock()
defer m.Unlock()
m.Latency = latency
m.LatencyMeasuredAt = time.Now()
m.persisted.UnSet()
}
// GetLatency returns the latency and when it expires.
func (m *Measurements) GetLatency() (latency time.Duration, measuredAt time.Time) {
m.Lock()
defer m.Unlock()
return m.Latency, m.LatencyMeasuredAt
}
// SetCapacity sets the capacity to the given value.
// The capacity is measued in bit/s.
func (m *Measurements) SetCapacity(capacity int) {
m.Lock()
defer m.Unlock()
m.Capacity = capacity
m.CapacityMeasuredAt = time.Now()
m.persisted.UnSet()
}
// GetCapacity returns the capacity and when it expires.
// The capacity is measued in bit/s.
func (m *Measurements) GetCapacity() (capacity int, measuredAt time.Time) {
m.Lock()
defer m.Unlock()
return m.Capacity, m.CapacityMeasuredAt
}
// SetCalculatedCost sets the calculated cost to the given value.
// The calculated cost is not set automatically, but needs to be set when needed.
func (m *Measurements) SetCalculatedCost(cost float32) {
m.Lock()
defer m.Unlock()
m.CalculatedCost = cost
m.persisted.UnSet()
}
// GetCalculatedCost returns the calculated cost.
// The calculated cost is not set automatically, but needs to be set when needed.
func (m *Measurements) GetCalculatedCost() (cost float32) {
if m == nil {
return MaxCalculatedCost
}
m.Lock()
defer m.Unlock()
return m.CalculatedCost
}
// SetGeoProximity sets the geolocation proximity to the given value.
func (m *Measurements) SetGeoProximity(geoProximity float32) {
m.Lock()
defer m.Unlock()
m.GeoProximity = geoProximity
m.persisted.UnSet()
}
// GetGeoProximity returns the geolocation proximity.
func (m *Measurements) GetGeoProximity() (geoProximity float32) {
if m == nil {
return 0
}
m.Lock()
defer m.Unlock()
return m.GeoProximity
}
var (
measurementsRegistry = make(map[string]*Measurements)
measurementsRegistryLock sync.Mutex
)
func getSharedMeasurements(hubID string, existing *Measurements) *Measurements {
measurementsRegistryLock.Lock()
defer measurementsRegistryLock.Unlock()
// 1. Check registry and return shared measurements.
m, ok := measurementsRegistry[hubID]
if ok {
return m
}
// 2. Use existing and make it shared, if available.
if existing != nil {
existing.check()
measurementsRegistry[hubID] = existing
return existing
}
// 3. Create new measurements.
m = NewMeasurements()
measurementsRegistry[hubID] = m
return m
}

308
spn/hub/status.go Normal file
View File

@@ -0,0 +1,308 @@
package hub
import (
"errors"
"fmt"
"sort"
"time"
"golang.org/x/exp/slices"
"github.com/safing/jess"
)
// VersionOffline is a special version used to signify that the Hub has gone offline.
// This is depracated, please use FlagOffline instead.
const VersionOffline = "offline"
// Status Flags.
const (
// FlagNetError signifies that the Hub reports a network connectivity failure or impairment.
FlagNetError = "net-error"
// FlagOffline signifies that the Hub has gone offline by itself.
FlagOffline = "offline"
// FlagAllowUnencrypted signifies that the Hub is available to handle unencrypted connections.
FlagAllowUnencrypted = "allow-unencrypted"
)
// Status is the message type used to update changing Hub Information. Changes are made automatically.
type Status struct {
Timestamp int64 `cbor:"t"`
// Version holds the current software version of the Hub.
Version string `cbor:"v"`
// Routing Information
Keys map[string]*Key `cbor:"k,omitempty" json:",omitempty"` // public keys (with type)
Lanes []*Lane `cbor:"c,omitempty" json:",omitempty"` // Connections to other Hubs.
// Status Information
// Load describes max(CPU, Memory) in percent, averaged over at least 15
// minutes. Load is published in fixed steps only.
Load int `cbor:"l,omitempty" json:",omitempty"`
// Flags holds flags that signify special states.
Flags []string `cbor:"f,omitempty" json:",omitempty"`
}
// Key represents a semi-ephemeral public key used for 0-RTT connection establishment.
type Key struct {
Scheme string
Key []byte
Expires int64
}
// Lane represents a connection to another Hub.
type Lane struct {
// ID is the Hub ID of the peer.
ID string
// Capacity designates the available bandwidth between these Hubs.
// It is specified in bit/s.
Capacity int
// Lateny designates the latency between these Hubs.
// It is specified in nanoseconds.
Latency time.Duration
}
// Copy returns a deep copy of the Status.
func (s *Status) Copy() *Status {
newStatus := &Status{
Timestamp: s.Timestamp,
Version: s.Version,
Lanes: slices.Clone(s.Lanes),
Load: s.Load,
Flags: slices.Clone(s.Flags),
}
// Copy map.
newStatus.Keys = make(map[string]*Key, len(s.Keys))
for k, v := range s.Keys {
newStatus.Keys[k] = v
}
return newStatus
}
// SelectSignet selects the public key to use for initiating connections to that Hub.
func (h *Hub) SelectSignet() *jess.Signet {
h.Lock()
defer h.Unlock()
// Return no Signet if we don't have a Status.
if h.Status == nil {
return nil
}
// TODO: select key based on preferred alg?
now := time.Now().Unix()
for id, key := range h.Status.Keys {
if now < key.Expires {
return &jess.Signet{
ID: id,
Scheme: key.Scheme,
Key: key.Key,
Public: true,
}
}
}
return nil
}
// GetSignet returns the public key identified by the given ID from the Hub Status.
func (h *Hub) GetSignet(id string, recipient bool) (*jess.Signet, error) {
h.Lock()
defer h.Unlock()
// check if public key is being requested
if !recipient {
return nil, jess.ErrSignetNotFound
}
// check if ID exists
key, ok := h.Status.Keys[id]
if !ok {
return nil, jess.ErrSignetNotFound
}
// transform and return
return &jess.Signet{
ID: id,
Scheme: key.Scheme,
Key: key.Key,
Public: true,
}, nil
}
// AddLane adds a new Lane to the Hub Status.
func (h *Hub) AddLane(newLane *Lane) error {
h.Lock()
defer h.Unlock()
// validity check
if h.Status == nil {
return ErrMissingInfo
}
// check if duplicate
for _, lane := range h.Status.Lanes {
if newLane.ID == lane.ID {
return errors.New("lane already exists")
}
}
// add
h.Status.Lanes = append(h.Status.Lanes, newLane)
return nil
}
// RemoveLane removes a Lane from the Hub Status.
func (h *Hub) RemoveLane(hubID string) error {
h.Lock()
defer h.Unlock()
// validity check
if h.Status == nil {
return ErrMissingInfo
}
for key, lane := range h.Status.Lanes {
if lane.ID == hubID {
h.Status.Lanes = append(h.Status.Lanes[:key], h.Status.Lanes[key+1:]...)
break
}
}
return nil
}
// GetLaneTo returns the lane to the given Hub, if it exists.
func (h *Hub) GetLaneTo(hubID string) *Lane {
h.Lock()
defer h.Unlock()
// validity check
if h.Status == nil {
return nil
}
for _, lane := range h.Status.Lanes {
if lane.ID == hubID {
return lane
}
}
return nil
}
// Equal returns whether the Lane is equal to the given one.
func (l *Lane) Equal(other *Lane) bool {
switch {
case l == nil || other == nil:
return false
case l.ID != other.ID:
return false
case l.Capacity != other.Capacity:
return false
case l.Latency != other.Latency:
return false
}
return true
}
// validateFormatting check if all values conform to the basic format.
func (s *Status) validateFormatting() error {
// public keys
if len(s.Keys) > 255 {
return fmt.Errorf("field Keys with array/slice length of %d exceeds max length of %d", len(s.Keys), 255)
}
for keyID, key := range s.Keys {
if err := checkStringFormat("Keys#ID", keyID, 255); err != nil {
return err
}
if err := checkStringFormat("Keys.Scheme", key.Scheme, 255); err != nil {
return err
}
if err := checkByteSliceFormat("Keys.Key", key.Key, 1024); err != nil {
return err
}
}
// connections
if len(s.Lanes) > 255 {
return fmt.Errorf("field Lanes with array/slice length of %d exceeds max length of %d", len(s.Lanes), 255)
}
for _, lanes := range s.Lanes {
if err := checkStringFormat("Lanes.ID", lanes.ID, 255); err != nil {
return err
}
}
// Flags
if err := checkStringSliceFormat("Flags", s.Flags, 255, 255); err != nil {
return err
}
return nil
}
func (l *Lane) String() string {
return fmt.Sprintf("<%s cap=%d lat=%d>", l.ID, l.Capacity, l.Latency)
}
// LanesEqual returns whether the given []*Lane are equal.
func LanesEqual(a, b []*Lane) bool {
if len(a) != len(b) {
return false
}
for i, l := range a {
if !l.Equal(b[i]) {
return false
}
}
return true
}
type lanes []*Lane
func (l lanes) Len() int { return len(l) }
func (l lanes) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l lanes) Less(i, j int) bool { return l[i].ID < l[j].ID }
// SortLanes sorts a slice of Lanes.
func SortLanes(l []*Lane) {
sort.Sort(lanes(l))
}
// HasFlag returns whether the Status has the given flag set.
func (s *Status) HasFlag(flagName string) bool {
return slices.Contains[[]string, string](s.Flags, flagName)
}
// FlagsEqual returns whether the given status flags are equal.
func FlagsEqual(a, b []string) bool {
// Cannot be equal if lengths are different.
if len(a) != len(b) {
return false
}
// If both are empty, they are equal.
if len(a) == 0 {
return true
}
// Make sure flags are sorted before comparing values.
sort.Strings(a)
sort.Strings(b)
// Compare values.
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}

152
spn/hub/transport.go Normal file
View File

@@ -0,0 +1,152 @@
package hub
import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"golang.org/x/exp/slices"
)
// Examples:
// "spn:17",
// "smtp:25",
// "smtp:587",
// "imap:143",
// "http:80",
// "http://example.com:80/example", // HTTP (based): use full path for request
// "https:443",
// "ws:80",
// "wss://example.com:443/spn",
// Transport represents a "endpoint" that others can connect to. This allows for use of different protocols, ports and infrastructure integration.
type Transport struct {
Protocol string
Domain string
Port uint16
Path string
Option string
}
// ParseTransports returns a list of parsed transports and errors from parsing
// the given definitions.
func ParseTransports(definitions []string) (transports []*Transport, errs []error) {
transports = make([]*Transport, 0, len(definitions))
for _, definition := range definitions {
parsed, err := ParseTransport(definition)
if err != nil {
errs = append(errs, fmt.Errorf(
"unknown or invalid transport %q: %w", definition, err,
))
} else {
transports = append(transports, parsed)
}
}
SortTransports(transports)
return transports, errs
}
// ParseTransport parses a transport definition.
func ParseTransport(definition string) (*Transport, error) {
u, err := url.Parse(definition)
if err != nil {
return nil, err
}
// check for invalid parts
if u.User != nil {
return nil, errors.New("user/pass is not allowed")
}
// put into transport
t := &Transport{
Protocol: u.Scheme,
Domain: u.Hostname(),
Path: u.RequestURI(),
Option: u.Fragment,
}
// parse port
portData := u.Port()
if portData == "" {
// no port available - it might be in u.Opaque, which holds both the port and possibly a path
portData = strings.SplitN(u.Opaque, "/", 2)[0] // get port
t.Path = strings.TrimPrefix(t.Path, portData) // trim port from path
// check again for port
if portData == "" {
return nil, errors.New("missing port")
}
}
port, err := strconv.ParseUint(portData, 10, 16)
if err != nil {
return nil, errors.New("invalid port")
}
t.Port = uint16(port)
// check port
if t.Port == 0 {
return nil, errors.New("invalid port")
}
// remove root paths
if t.Path == "/" {
t.Path = ""
}
// check for protocol
if t.Protocol == "" {
return nil, errors.New("missing scheme/protocol")
}
return t, nil
}
// String returns the definition form of the transport.
func (t *Transport) String() string {
switch {
case t.Option != "":
return fmt.Sprintf("%s://%s:%d%s#%s", t.Protocol, t.Domain, t.Port, t.Path, t.Option)
case t.Domain != "":
return fmt.Sprintf("%s://%s:%d%s", t.Protocol, t.Domain, t.Port, t.Path)
default:
return fmt.Sprintf("%s:%d%s", t.Protocol, t.Port, t.Path)
}
}
// SortTransports sorts the transports to emphasize certain protocols, but
// otherwise leaves the order intact.
func SortTransports(ts []*Transport) {
slices.SortStableFunc[[]*Transport, *Transport](ts, func(a, b *Transport) int {
aOrder := a.protocolOrder()
bOrder := b.protocolOrder()
switch {
case aOrder != bOrder:
return aOrder - bOrder
// case a.Port != b.Port:
// return int(a.Port) - int(b.Port)
// case a.Domain != b.Domain:
// return strings.Compare(a.Domain, b.Domain)
// case a.Path != b.Path:
// return strings.Compare(a.Path, b.Path)
// case a.Option != b.Option:
// return strings.Compare(a.Option, b.Option)
default:
return 0
}
})
}
func (t *Transport) protocolOrder() int {
switch t.Protocol {
case "http":
return 1
case "spn":
return 2
default:
return 100
}
}

147
spn/hub/transport_test.go Normal file
View File

@@ -0,0 +1,147 @@
package hub
import (
"testing"
"github.com/stretchr/testify/assert"
)
func parseT(t *testing.T, definition string) *Transport {
t.Helper()
tr, err := ParseTransport(definition)
if err != nil {
t.Fatal(err)
return nil
}
return tr
}
func parseTError(definition string) error {
_, err := ParseTransport(definition)
return err
}
func TestTransportParsing(t *testing.T) {
t.Parallel()
// test parsing
assert.Equal(t, &Transport{
Protocol: "spn",
Port: 17,
}, parseT(t, "spn:17"), "should match")
assert.Equal(t, &Transport{
Protocol: "smtp",
Port: 25,
}, parseT(t, "smtp:25"), "should match")
assert.Equal(t, &Transport{
Protocol: "smtp",
Port: 25,
}, parseT(t, "smtp://:25"), "should match")
assert.Equal(t, &Transport{
Protocol: "smtp",
Port: 587,
}, parseT(t, "smtp:587"), "should match")
assert.Equal(t, &Transport{
Protocol: "imap",
Port: 143,
}, parseT(t, "imap:143"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Port: 80,
}, parseT(t, "http:80"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Domain: "example.com",
Port: 80,
}, parseT(t, "http://example.com:80"), "should match")
assert.Equal(t, &Transport{
Protocol: "https",
Port: 443,
}, parseT(t, "https:443"), "should match")
assert.Equal(t, &Transport{
Protocol: "ws",
Port: 80,
}, parseT(t, "ws:80"), "should match")
assert.Equal(t, &Transport{
Protocol: "wss",
Domain: "example.com",
Port: 443,
Path: "/spn",
}, parseT(t, "wss://example.com:443/spn"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Domain: "example.com",
Port: 80,
}, parseT(t, "http://example.com:80"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Domain: "example.com",
Port: 80,
Path: "/test%20test",
}, parseT(t, "http://example.com:80/test test"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Domain: "example.com",
Port: 80,
Path: "/test%20test",
}, parseT(t, "http://example.com:80/test%20test"), "should match")
assert.Equal(t, &Transport{
Protocol: "http",
Domain: "example.com",
Port: 80,
Path: "/test?key=value",
}, parseT(t, "http://example.com:80/test?key=value"), "should match")
// test parsing and formatting
assert.Equal(t, "spn:17",
parseT(t, "spn:17").String(), "should match")
assert.Equal(t, "smtp:25",
parseT(t, "smtp:25").String(), "should match")
assert.Equal(t, "smtp:25",
parseT(t, "smtp://:25").String(), "should match")
assert.Equal(t, "smtp:587",
parseT(t, "smtp:587").String(), "should match")
assert.Equal(t, "imap:143",
parseT(t, "imap:143").String(), "should match")
assert.Equal(t, "http:80",
parseT(t, "http:80").String(), "should match")
assert.Equal(t, "http://example.com:80",
parseT(t, "http://example.com:80").String(), "should match")
assert.Equal(t, "https:443",
parseT(t, "https:443").String(), "should match")
assert.Equal(t, "ws:80",
parseT(t, "ws:80").String(), "should match")
assert.Equal(t, "wss://example.com:443/spn",
parseT(t, "wss://example.com:443/spn").String(), "should match")
assert.Equal(t, "http://example.com:80",
parseT(t, "http://example.com:80").String(), "should match")
assert.Equal(t, "http://example.com:80/test%20test",
parseT(t, "http://example.com:80/test test").String(), "should match")
assert.Equal(t, "http://example.com:80/test%20test",
parseT(t, "http://example.com:80/test%20test").String(), "should match")
assert.Equal(t, "http://example.com:80/test?key=value",
parseT(t, "http://example.com:80/test?key=value").String(), "should match")
// test invalid
assert.NotEqual(t, parseTError("spn"), nil, "should fail")
assert.NotEqual(t, parseTError("spn:"), nil, "should fail")
assert.NotEqual(t, parseTError("spn:0"), nil, "should fail")
assert.NotEqual(t, parseTError("spn:65536"), nil, "should fail")
}

17
spn/hub/truststores.go Normal file
View File

@@ -0,0 +1,17 @@
package hub
import "github.com/safing/jess"
// SingleTrustStore is a simple truststore that always returns the same Signet.
type SingleTrustStore struct {
Signet *jess.Signet
}
// GetSignet implements the truststore interface.
func (ts *SingleTrustStore) GetSignet(id string, recipient bool) (*jess.Signet, error) {
if ts.Signet.ID != id || recipient != ts.Signet.Public {
return nil, jess.ErrSignetNotFound
}
return ts.Signet, nil
}

524
spn/hub/update.go Normal file
View File

@@ -0,0 +1,524 @@
package hub
import (
"errors"
"fmt"
"time"
"github.com/safing/jess"
"github.com/safing/jess/lhash"
"github.com/safing/portbase/container"
"github.com/safing/portbase/database"
"github.com/safing/portbase/formats/dsd"
"github.com/safing/portbase/log"
"github.com/safing/portmaster/service/network/netutils"
)
var (
// hubMsgRequirements defines which security attributes message need to have.
hubMsgRequirements = jess.NewRequirements().
Remove(jess.RecipientAuthentication). // Recipient don't need a private key.
Remove(jess.Confidentiality). // Message contents are out in the open.
Remove(jess.Integrity) // Only applies to decryption.
// SenderAuthentication provides pre-decryption integrity. That is all we need.
clockSkewTolerance = 1 * time.Hour
)
// SignHubMsg signs the given serialized hub msg with the given configuration.
func SignHubMsg(msg []byte, env *jess.Envelope, enableTofu bool) ([]byte, error) {
// start session from envelope
session, err := env.Correspondence(nil)
if err != nil {
return nil, fmt.Errorf("failed to initiate signing session: %w", err)
}
// sign the data
letter, err := session.Close(msg)
if err != nil {
return nil, fmt.Errorf("failed to sign msg: %w", err)
}
if enableTofu {
// smuggle the public key
// letter.Keys is usually only used for key exchanges and encapsulation
// neither is used when signing, so we can use letter.Keys to transport public keys
for _, sender := range env.Senders {
// get public key
public, err := sender.AsRecipient()
if err != nil {
return nil, fmt.Errorf("failed to get public key of %s: %w", sender.ID, err)
}
// serialize key
err = public.StoreKey()
if err != nil {
return nil, fmt.Errorf("failed to serialize public key %s: %w", sender.ID, err)
}
// add to keys
letter.Keys = append(letter.Keys, &jess.Seal{
Value: public.Key,
})
}
}
// pack
data, err := letter.ToDSD(dsd.JSON)
if err != nil {
return nil, err
}
return data, nil
}
// OpenHubMsg opens a signed hub msg and verifies the signature using the
// provided hub or the local database. If TOFU is enabled, the signature is
// always accepted, if valid.
func OpenHubMsg(hub *Hub, data []byte, mapName string, tofu bool) (msg []byte, sendingHub *Hub, known bool, err error) {
letter, err := jess.LetterFromDSD(data)
if err != nil {
return nil, nil, false, fmt.Errorf("malformed letter: %w", err)
}
// check signatures
var seal *jess.Seal
switch len(letter.Signatures) {
case 0:
return nil, nil, false, errors.New("missing signature")
case 1:
seal = letter.Signatures[0]
default:
return nil, nil, false, fmt.Errorf("too many signatures (%d)", len(letter.Signatures))
}
// check signature signer ID
if seal.ID == "" {
return nil, nil, false, errors.New("signature is missing signer ID")
}
// get hub for public key
if hub == nil {
hub, err = GetHub(mapName, seal.ID)
if err != nil {
if !errors.Is(err, database.ErrNotFound) {
return nil, nil, false, fmt.Errorf("failed to get existing hub %s: %w", seal.ID, err)
}
hub = nil
} else {
known = true
}
} else {
known = true
}
var truststore jess.TrustStore
if hub != nil && hub.PublicKey != nil { // bootstrap entries will not have a public key
// check ID integrity
if hub.ID != seal.ID {
return nil, hub, known, fmt.Errorf("ID mismatch with hub msg ID %s and hub ID %s", seal.ID, hub.ID)
}
if !verifyHubID(seal.ID, hub.PublicKey.Scheme, hub.PublicKey.Key) {
return nil, hub, known, fmt.Errorf("ID integrity of %s violated with existing key", seal.ID)
}
} else {
if !tofu {
return nil, nil, false, fmt.Errorf("hub msg ID %s unknown (missing announcement)", seal.ID)
}
// trust on first use, extract key from keys
// TODO: Test if works without TOFU.
// get key
var pubkey *jess.Seal
switch len(letter.Keys) {
case 0:
return nil, nil, false, fmt.Errorf("missing key for TOFU of %s", seal.ID)
case 1:
pubkey = letter.Keys[0]
default:
return nil, nil, false, fmt.Errorf("too many keys (%d) for TOFU of %s", len(letter.Keys), seal.ID)
}
// check ID integrity
if !verifyHubID(seal.ID, seal.Scheme, pubkey.Value) {
return nil, nil, false, fmt.Errorf("ID integrity of %s violated with new key", seal.ID)
}
hub = &Hub{
ID: seal.ID,
Map: mapName,
PublicKey: &jess.Signet{
ID: seal.ID,
Scheme: seal.Scheme,
Key: pubkey.Value,
Public: true,
},
}
err = hub.PublicKey.LoadKey()
if err != nil {
return nil, nil, false, err
}
}
// create trust store
truststore = &SingleTrustStore{hub.PublicKey}
// remove keys from letter, as they are only used to transfer the public key
letter.Keys = nil
// check signature
err = letter.Verify(hubMsgRequirements, truststore)
if err != nil {
return nil, nil, false, err
}
return letter.Data, hub, known, nil
}
// Export exports the announcement with the given signature configuration.
func (a *Announcement) Export(env *jess.Envelope) ([]byte, error) {
// pack
msg, err := dsd.Dump(a, dsd.JSON)
if err != nil {
return nil, fmt.Errorf("failed to pack announcement: %w", err)
}
return SignHubMsg(msg, env, true)
}
// ApplyAnnouncement applies the announcement to the Hub if it passes all the
// checks. If no Hub is provided, it is loaded from the database or created.
func ApplyAnnouncement(existingHub *Hub, data []byte, mapName string, scope Scope, selfcheck bool) (hub *Hub, known, changed bool, err error) {
// Set valid/invalid status based on the return error.
var announcement *Announcement
defer func() {
if hub != nil {
if err != nil && !errors.Is(err, ErrOldData) {
hub.InvalidInfo = true
} else {
hub.InvalidInfo = false
}
}
}()
// open and verify
var msg []byte
msg, hub, known, err = OpenHubMsg(existingHub, data, mapName, true)
// Lock hub if we have one.
if hub != nil && !selfcheck {
hub.Lock()
defer hub.Unlock()
}
// Check if there was an error with the Hub msg.
if err != nil {
return //nolint:nakedret
}
// parse
announcement = &Announcement{}
_, err = dsd.Load(msg, announcement)
if err != nil {
return //nolint:nakedret
}
// integrity check
// `hub.ID` is taken from the first ever received announcement message.
// `announcement.ID` is additionally present in the message as we need
// a signed version of the ID to mitigate fake IDs.
// Fake IDs are possible because the hash algorithm of the ID is dynamic.
if hub.ID != announcement.ID {
err = fmt.Errorf("announcement ID %q mismatches hub ID %q", announcement.ID, hub.ID)
return //nolint:nakedret
}
// version check
if hub.Info != nil {
// check if we already have this version
switch {
case announcement.Timestamp == hub.Info.Timestamp && !selfcheck:
// The new copy is not saved, as we expect the versions to be identical.
// Also, the new version has not been validated at this point.
return //nolint:nakedret
case announcement.Timestamp < hub.Info.Timestamp:
// Received an old version, do not update.
err = fmt.Errorf(
"%wannouncement from %s @ %s is older than current status @ %s",
ErrOldData, hub.StringWithoutLocking(), time.Unix(announcement.Timestamp, 0), time.Unix(hub.Info.Timestamp, 0),
)
return //nolint:nakedret
}
}
// We received a new version.
changed = true
// Update timestamp here already in case validation fails.
if hub.Info != nil {
hub.Info.Timestamp = announcement.Timestamp
}
// Validate the announcement.
err = hub.validateAnnouncement(announcement, scope)
if err != nil {
if selfcheck || hub.FirstSeen.IsZero() {
err = fmt.Errorf("failed to validate announcement of %s: %w", hub.StringWithoutLocking(), err)
return //nolint:nakedret
}
log.Warningf("spn/hub: received an invalid announcement of %s: %s", hub.StringWithoutLocking(), err)
// If a previously fully validated Hub publishes an update that breaks it, a
// soft-fail will accept the faulty changes, but mark is as invalid and
// forward it to neighbors. This way the invalid update is propagated through
// the network and all nodes will mark it as invalid an thus ingore the Hub
// until the issue is fixed.
}
// Only save announcement if it is valid.
if err == nil {
hub.Info = announcement
}
// Set FirstSeen timestamp when we see this Hub for the first time.
if hub.FirstSeen.IsZero() {
hub.FirstSeen = time.Now().UTC()
}
return //nolint:nakedret
}
func (h *Hub) validateAnnouncement(announcement *Announcement, scope Scope) error {
// value formatting
if err := announcement.validateFormatting(); err != nil {
return err
}
// check parsables
if err := announcement.prepare(true); err != nil {
return fmt.Errorf("failed to prepare announcement: %w", err)
}
// check timestamp
if announcement.Timestamp > time.Now().Add(clockSkewTolerance).Unix() {
return fmt.Errorf(
"announcement from %s @ %s is from the future",
announcement.ID,
time.Unix(announcement.Timestamp, 0),
)
}
// check for illegal IP address changes
if h.Info != nil {
switch {
case h.Info.IPv4 != nil && announcement.IPv4 == nil:
h.VerifiedIPs = false
return errors.New("previously announced IPv4 address missing")
case h.Info.IPv4 != nil && !announcement.IPv4.Equal(h.Info.IPv4):
h.VerifiedIPs = false
return errors.New("IPv4 address changed")
case h.Info.IPv6 != nil && announcement.IPv6 == nil:
h.VerifiedIPs = false
return errors.New("previously announced IPv6 address missing")
case h.Info.IPv6 != nil && !announcement.IPv6.Equal(h.Info.IPv6):
h.VerifiedIPs = false
return errors.New("IPv6 address changed")
}
}
// validate IP scopes
if announcement.IPv4 != nil {
ipScope := netutils.GetIPScope(announcement.IPv4)
switch {
case scope == ScopeLocal && !ipScope.IsLAN():
return errors.New("IPv4 scope violation: outside of local scope")
case scope == ScopePublic && !ipScope.IsGlobal():
return errors.New("IPv4 scope violation: outside of global scope")
}
// Reset IP verification flag if IPv4 was added.
if h.Info == nil || h.Info.IPv4 == nil {
h.VerifiedIPs = false
}
}
if announcement.IPv6 != nil {
ipScope := netutils.GetIPScope(announcement.IPv6)
switch {
case scope == ScopeLocal && !ipScope.IsLAN():
return errors.New("IPv6 scope violation: outside of local scope")
case scope == ScopePublic && !ipScope.IsGlobal():
return errors.New("IPv6 scope violation: outside of global scope")
}
// Reset IP verification flag if IPv6 was added.
if h.Info == nil || h.Info.IPv6 == nil {
h.VerifiedIPs = false
}
}
return nil
}
// Export exports the status with the given signature configuration.
func (s *Status) Export(env *jess.Envelope) ([]byte, error) {
// pack
msg, err := dsd.Dump(s, dsd.JSON)
if err != nil {
return nil, fmt.Errorf("failed to pack status: %w", err)
}
return SignHubMsg(msg, env, false)
}
// ApplyStatus applies a status update if it passes all the checks.
func ApplyStatus(existingHub *Hub, data []byte, mapName string, scope Scope, selfcheck bool) (hub *Hub, known, changed bool, err error) {
// Set valid/invalid status based on the return error.
defer func() {
if hub != nil {
if err != nil && !errors.Is(err, ErrOldData) {
hub.InvalidStatus = true
} else {
hub.InvalidStatus = false
}
}
}()
// open and verify
var msg []byte
msg, hub, known, err = OpenHubMsg(existingHub, data, mapName, false)
// Lock hub if we have one.
if hub != nil && !selfcheck {
hub.Lock()
defer hub.Unlock()
}
// Check if there was an error with the Hub msg.
if err != nil {
return //nolint:nakedret
}
// parse
status := &Status{}
_, err = dsd.Load(msg, status)
if err != nil {
return //nolint:nakedret
}
// version check
if hub.Status != nil {
// check if we already have this version
switch {
case status.Timestamp == hub.Status.Timestamp && !selfcheck:
// The new copy is not saved, as we expect the versions to be identical.
// Also, the new version has not been validated at this point.
return //nolint:nakedret
case status.Timestamp < hub.Status.Timestamp:
// Received an old version, do not update.
err = fmt.Errorf(
"%wstatus from %s @ %s is older than current status @ %s",
ErrOldData, hub.StringWithoutLocking(), time.Unix(status.Timestamp, 0), time.Unix(hub.Status.Timestamp, 0),
)
return //nolint:nakedret
}
}
// We received a new version.
changed = true
// Update timestamp here already in case validation fails.
if hub.Status != nil {
hub.Status.Timestamp = status.Timestamp
}
// Validate the status.
err = hub.validateStatus(status)
if err != nil {
if selfcheck {
err = fmt.Errorf("failed to validate status of %s: %w", hub.StringWithoutLocking(), err)
return //nolint:nakedret
}
log.Warningf("spn/hub: received an invalid status of %s: %s", hub.StringWithoutLocking(), err)
// If a previously fully validated Hub publishes an update that breaks it, a
// soft-fail will accept the faulty changes, but mark is as invalid and
// forward it to neighbors. This way the invalid update is propagated through
// the network and all nodes will mark it as invalid an thus ingore the Hub
// until the issue is fixed.
}
// Only save status if it is valid, else mark it as invalid.
if err == nil {
hub.Status = status
}
return //nolint:nakedret
}
func (h *Hub) validateStatus(status *Status) error {
// value formatting
if err := status.validateFormatting(); err != nil {
return err
}
// check timestamp
if status.Timestamp > time.Now().Add(clockSkewTolerance).Unix() {
return fmt.Errorf(
"status from %s @ %s is from the future",
h.ID,
time.Unix(status.Timestamp, 0),
)
}
// TODO: validate status.Keys
return nil
}
// CreateHubSignet creates a signet with the correct ID for usage as a Hub Identity.
func CreateHubSignet(toolID string, securityLevel int) (private, public *jess.Signet, err error) {
private, err = jess.GenerateSignet(toolID, securityLevel)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate key: %w", err)
}
err = private.StoreKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to store private key: %w", err)
}
// get public key for creating the Hub ID
public, err = private.AsRecipient()
if err != nil {
return nil, nil, fmt.Errorf("failed to get public key: %w", err)
}
err = public.StoreKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to store public key: %w", err)
}
// assign IDs
private.ID = createHubID(public.Scheme, public.Key)
public.ID = private.ID
return private, public, nil
}
func createHubID(scheme string, pubkey []byte) string {
// compile scheme and public key
c := container.New()
c.AppendAsBlock([]byte(scheme))
c.AppendAsBlock(pubkey)
return lhash.Digest(lhash.BLAKE2b_256, c.CompileData()).Base58()
}
func verifyHubID(id string, scheme string, pubkey []byte) (ok bool) {
// load labeled hash from ID
labeledHash, err := lhash.FromBase58(id)
if err != nil {
return false
}
// compile scheme and public key
c := container.New()
c.AppendAsBlock([]byte(scheme))
c.AppendAsBlock(pubkey)
// check if it matches
return labeledHash.MatchesData(c.CompileData())
}

70
spn/hub/update_test.go Normal file
View File

@@ -0,0 +1,70 @@
package hub
import (
"fmt"
"testing"
"github.com/safing/jess"
"github.com/safing/portbase/formats/dsd"
)
func TestHubUpdate(t *testing.T) {
t.Parallel()
// message signing
testData := []byte{0}
s1, err := jess.GenerateSignet("Ed25519", 0)
if err != nil {
t.Fatal(err)
}
err = s1.StoreKey()
if err != nil {
t.Fatal(err)
}
fmt.Printf("s1: %+v\n", s1)
s1e, err := s1.AsRecipient()
if err != nil {
t.Fatal(err)
}
err = s1e.StoreKey()
if err != nil {
t.Fatal(err)
}
s1e.ID = createHubID(s1e.Scheme, s1e.Key)
s1.ID = s1e.ID
t.Logf("generated hub ID: %s", s1.ID)
env := jess.NewUnconfiguredEnvelope()
env.SuiteID = jess.SuiteSignV1
env.Senders = []*jess.Signet{s1}
s, err := env.Correspondence(nil)
if err != nil {
t.Fatal(err)
}
letter, err := s.Close(testData)
if err != nil {
t.Fatal(err)
}
// smuggle the key
letter.Keys = append(letter.Keys, &jess.Seal{
Value: s1e.Key,
})
t.Logf("letter with smuggled key: %+v", letter)
// pack
data, err := letter.ToDSD(dsd.JSON)
if err != nil {
t.Fatal(err)
}
_, _, _, err = OpenHubMsg(nil, data, "test", true) //nolint:dogsled
if err != nil {
t.Fatal(err)
}
}