Reticulum-Go/pkg/pageserver/node.go

989 lines
28 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
package pageserver
import (
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unicode/utf8"
"quad4/msgpack/v5/pkg/msgpack"
"quad4/reticulum-go/pkg/buffer"
"quad4/reticulum-go/pkg/channel"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/debug"
"quad4/reticulum-go/pkg/destination"
"quad4/reticulum-go/pkg/identity"
"quad4/reticulum-go/pkg/interfaces"
"quad4/reticulum-go/pkg/link"
"quad4/reticulum-go/pkg/packet"
"quad4/reticulum-go/pkg/sandbox"
"quad4/reticulum-go/pkg/transport"
"quad4/reticulum-go/pkg/pageserver/dynamicpage"
)
// Reticulum owns the transport, destination, static /page and /file handlers, and announce history.
type Reticulum struct {
config *common.ReticulumConfig
transport *transport.Transport
interfaces []interfaces.Interface
channels map[string]*channel.Channel
buffers map[string]*buffer.Buffer
pathRequests map[string]*common.PathRequest
announceHistory map[string]announceRecord
announceHistoryMu sync.RWMutex
identity *identity.Identity
destination *destination.Destination
maxTransferSize int16
nodeEnabled bool
nodeTimestamp int64
pagesPath string
filesPath string
establishedLinks map[string]*link.Link
linksMutex sync.RWMutex
pagesRefreshInterval time.Duration
filesRefreshInterval time.Duration
registeredPagePaths map[string]struct{}
registeredFilePaths map[string]struct{}
staticPagesMu sync.Mutex
staticFilesMu sync.Mutex
refreshStop chan struct{}
refreshOnce sync.Once
pageStatsMu sync.RWMutex
pageStats map[string]int64
pageStatsDisabled bool
announceEveryMinutes int
}
type announceRecord struct {
timestamp int64
appData []byte
}
// NewReticulum loads or creates identity, opens the destination, wires link and static handlers, and builds interfaces from cfg.
func NewReticulum(cfg *common.ReticulumConfig, opts Options) (*Reticulum, error) {
if cfg == nil {
cfg = common.DefaultConfig()
}
sandbox.SetExecRlimits(cfg.SandboxExecRlimits)
if opts.NodeDisplayName == "" {
opts.NodeDisplayName = AppName
}
cfg.AppName = opts.NodeDisplayName
if cfg.AppAspect == "" {
cfg.AppAspect = AppAspect
}
if err := InitializeDirectories(); err != nil {
return nil, fmt.Errorf("failed to initialize directories: %v", err)
}
debug.Log(debug.DebugInfo, "Directories initialized")
t := transport.NewTransport(cfg)
debug.Log(debug.DebugInfo, "Transport initialized")
identityPath := ResolveIdentityPath(opts.IdentityFileOverride)
var ident *identity.Identity
if _, err := os.Stat(identityPath); err == nil {
ident, err = identity.LoadIdentityFile(identityPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to load identity: %v", err)
}
debug.Log(debug.DebugError, "Loaded existing identity", "hash", fmt.Sprintf("%x", ident.Hash()))
} else {
ident, err = identity.NewIdentity()
if err != nil {
return nil, fmt.Errorf("failed to create identity: %v", err)
}
debug.Log(debug.DebugError, "Created new identity", "hash", fmt.Sprintf("%x", ident.Hash()))
if err := ident.ToFile(identityPath); err != nil {
debug.Log(debug.DebugError, "Failed to save identity to file", "error", err)
} else {
debug.Log(debug.DebugInfo, "Identity saved to file", "path", identityPath)
}
}
t.SetIdentity(ident)
debug.Log(debug.DebugInfo, "Creating destination...")
dest, err := destination.New(
ident,
destination.In,
destination.Single,
"nomadnetwork",
t,
"node",
)
if err != nil {
return nil, fmt.Errorf("failed to create destination: %v", err)
}
debug.Log(debug.DebugInfo, "Created destination with hash", "hash", fmt.Sprintf("%x", dest.GetHash()))
nodeTimestamp := time.Now().Unix()
var pageStats map[string]int64
if !opts.DisablePageStats {
pageStats = make(map[string]int64)
}
r := &Reticulum{
config: cfg,
transport: t,
interfaces: make([]interfaces.Interface, 0),
channels: make(map[string]*channel.Channel),
buffers: make(map[string]*buffer.Buffer),
pathRequests: make(map[string]*common.PathRequest),
announceHistory: make(map[string]announceRecord),
identity: ident,
destination: dest,
maxTransferSize: 500,
nodeEnabled: true,
nodeTimestamp: nodeTimestamp,
pagesPath: opts.PagesDir,
filesPath: opts.FilesDir,
establishedLinks: make(map[string]*link.Link),
pagesRefreshInterval: opts.PageRefreshInterval,
filesRefreshInterval: opts.FileRefreshInterval,
refreshStop: make(chan struct{}),
pageStats: pageStats,
pageStatsDisabled: opts.DisablePageStats,
announceEveryMinutes: opts.AnnounceIntervalMinutes,
}
dest.AcceptsLinks(true)
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
ratchetPath := destPrivateRatchetPath(filepath.Join(homeDir, ".reticulum-go"), dest, ident)
dest.EnableRatchets(ratchetPath)
dest.SetProofStrategy(destination.ProveApp)
dest.SetLinkEstablishedCallback(r.onLinkEstablished)
debug.Log(debug.DebugVerbose, "Configured destination features")
r.registerStaticHandlers()
for name, ifaceConfig := range cfg.Interfaces {
if !ifaceConfig.Enabled {
continue
}
iface, err := interfaces.NewFromConfig(name, ifaceConfig)
if err != nil {
if cfg.PanicOnInterfaceErr {
return nil, fmt.Errorf("failed to create interface %s: %v", name, err)
}
debug.Log(debug.DebugCritical, "Error creating interface", "name", name, "error", err)
continue
}
debug.Log(debug.DebugError, "Configuring interface", "name", name, "type", ifaceConfig.Type)
r.interfaces = append(r.interfaces, iface)
debug.Log(debug.DebugInfo, "Interface configured", "name", name)
}
return r, nil
}
func (r *Reticulum) handleInterface(iface common.NetworkInterface) {
debug.Log(debug.DebugInfo, "Setting up interface", "name", iface.GetName(), "type", fmt.Sprintf("%T", iface))
ch := channel.NewChannel(&transportWrapper{r.transport})
r.channels[iface.GetName()] = ch
rw := buffer.CreateBidirectionalBuffer(
1,
2,
ch,
func(size int) {
data := make([]byte, size)
debug.Log(debug.DebugPackets, "Interface reading bytes from buffer", "name", iface.GetName(), "size", size)
iface.ProcessIncoming(data)
if len(data) > 0 {
debug.Log(debug.DebugTrace, "Interface received packet type", "name", iface.GetName(), "type", fmt.Sprintf("0x%02x", data[0]))
r.transport.HandlePacket(data, iface)
}
},
)
r.buffers[iface.GetName()] = &buffer.Buffer{
ReadWriter: rw,
}
}
func (r *Reticulum) Transport() *transport.Transport {
return r.transport
}
func (r *Reticulum) MonitorInterfaces() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
for _, iface := range r.interfaces {
if tcpClient, ok := iface.(*interfaces.TCPClientInterface); ok {
stats := fmt.Sprintf("Interface %s status - Connected: %v, TX: %d bytes (%.2f Kbps), RX: %d bytes (%.2f Kbps)",
iface.GetName(),
tcpClient.IsConnected(),
tcpClient.GetTxBytes(),
float64(tcpClient.GetTxBytes()*8)/(5*1024),
tcpClient.GetRxBytes(),
float64(tcpClient.GetRxBytes()*8)/(5*1024),
)
if runtime.GOOS != "windows" {
stats = fmt.Sprintf("%s, RTT: %v", stats, tcpClient.GetRTT())
}
debug.Log(debug.DebugVerbose, "Interface status", "stats", stats)
}
}
}
}
type transportWrapper struct {
*transport.Transport
}
func (tw *transportWrapper) GetRTT() float64 {
return 0.1
}
func (tw *transportWrapper) RTT() float64 {
return tw.GetRTT()
}
func (tw *transportWrapper) GetStatus() byte {
return transport.StatusActive
}
func (tw *transportWrapper) Send(data []byte) any {
p := &packet.Packet{
PacketType: packet.PacketTypeData,
Hops: 0,
Data: data,
HeaderType: packet.HeaderType1,
}
err := tw.Transport.SendPacket(p)
if err != nil {
return nil
}
return p
}
func (tw *transportWrapper) Resend(p any) error {
if pkt, ok := p.(*packet.Packet); ok {
return tw.Transport.SendPacket(pkt)
}
return fmt.Errorf("invalid packet type")
}
func (tw *transportWrapper) SetPacketTimeout(packet any, callback func(any), timeout time.Duration) {
time.AfterFunc(timeout, func() {
callback(packet)
})
}
func (tw *transportWrapper) SetPacketDelivered(packet any, callback func(any)) {
callback(packet)
}
func (tw *transportWrapper) GetLinkID() []byte {
return nil
}
func (tw *transportWrapper) HandleInbound(pkt *packet.Packet) error {
return nil
}
func (tw *transportWrapper) ValidateLinkProof(pkt *packet.Packet, networkIface common.NetworkInterface) error {
return nil
}
func (tw *transportWrapper) LinkedNetworkInterface() common.NetworkInterface {
return nil
}
func InitializeDirectories() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %v", err)
}
basePath := filepath.Join(homeDir, ".reticulum-go")
dirs := []string{
basePath,
filepath.Join(basePath, "storage"),
filepath.Join(basePath, "storage", "destinations"),
filepath.Join(basePath, "storage", "identities"),
filepath.Join(basePath, "storage", "ratchets"),
filepath.Join(basePath, "storage", "cache"),
filepath.Join(basePath, "storage", "cache", "announces"),
filepath.Join(basePath, "storage", "resources"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0700); err != nil { // #nosec G301
return fmt.Errorf("failed to create directory %s: %v", dir, err)
}
}
return nil
}
// Start boots the transport, registers interfaces, sends the initial announce, and starts optional refresh loops.
func (r *Reticulum) Start() error {
debug.Log(debug.DebugError, "Starting Reticulum...")
if err := r.transport.Start(); err != nil {
return fmt.Errorf("failed to start transport: %v", err)
}
debug.Log(debug.DebugInfo, "Transport started successfully")
if err := r.transport.InitializePathRequestHandler(); err != nil {
debug.Log(debug.DebugError, "Failed to initialize path request handler", "error", err)
}
for _, iface := range r.interfaces {
debug.Log(debug.DebugError, "Starting interface", "name", iface.GetName())
if err := iface.Start(); err != nil {
if r.config.PanicOnInterfaceErr {
return fmt.Errorf("failed to start interface %s: %v", iface.GetName(), err)
}
debug.Log(debug.DebugCritical, "Error starting interface", "name", iface.GetName(), "error", err)
continue
}
if netIface, ok := iface.(common.NetworkInterface); ok {
if err := r.transport.RegisterInterface(iface.GetName(), netIface); err != nil {
debug.Log(debug.DebugCritical, "Failed to register interface with transport", "name", iface.GetName(), "error", err)
} else {
debug.Log(debug.DebugInfo, "Registered interface with transport", "name", iface.GetName())
}
r.handleInterface(netIface)
}
debug.Log(debug.DebugInfo, "Interface started successfully", "name", iface.GetName())
}
time.Sleep(2 * time.Second)
nodeName := r.config.AppName
if nodeName == "" {
nodeName = AppName
}
debug.Log(debug.DebugInfo, "Sending initial announce",
"dest_hash", fmt.Sprintf("%x", r.destination.GetHash()),
"node_name", nodeName,
)
r.destination.SetDefaultAppData([]byte(nodeName))
announceStartTime := time.Now()
if err := r.destination.Announce(false, nil, nil); err != nil {
debug.Log(debug.DebugCritical, "Failed to send initial announce", "error", err, "elapsed", time.Since(announceStartTime).Seconds())
} else {
debug.Log(debug.DebugInfo, "Initial announce sent successfully", "elapsed", time.Since(announceStartTime).Seconds())
}
interval := time.Duration(r.announceEveryMinutes) * time.Minute
if r.announceEveryMinutes <= 0 {
debug.Log(debug.DebugInfo, "Periodic announces disabled (announce-interval 0). Only the initial announce was sent")
} else {
if interval < MinAnnounceInterval {
debug.Log(debug.DebugCritical,
"Configured announce-interval is below the minimum. Clamping to avoid flooding peers",
"requested_minutes", r.announceEveryMinutes,
"min", FormatDuration(MinAnnounceInterval),
)
interval = MinAnnounceInterval
}
debug.Log(debug.DebugInfo, "Starting periodic announce loop",
"interval_minutes", r.announceEveryMinutes,
"interval", FormatDuration(interval),
)
go func(period time.Duration) {
ticker := time.NewTicker(period)
defer ticker.Stop()
for range ticker.C {
debug.Log(debug.DebugInfo, "Sending periodic announce",
"every", FormatDuration(period),
)
if err := r.destination.Announce(false, nil, nil); err != nil {
debug.Log(debug.DebugCritical, "Could not send announce", "error", err)
}
}
}(interval)
}
if r.pagesRefreshInterval > 0 {
go r.staticPagesRefreshLoop()
}
if r.filesRefreshInterval > 0 {
go r.staticFilesRefreshLoop()
}
debug.Log(debug.DebugError, "Reticulum started successfully")
return nil
}
func (r *Reticulum) staticPagesRefreshLoop() {
ticker := time.NewTicker(r.pagesRefreshInterval)
defer ticker.Stop()
for {
select {
case <-r.refreshStop:
return
case <-ticker.C:
r.syncPageHandlers()
}
}
}
func (r *Reticulum) staticFilesRefreshLoop() {
ticker := time.NewTicker(r.filesRefreshInterval)
defer ticker.Stop()
for {
select {
case <-r.refreshStop:
return
case <-ticker.C:
r.syncFileHandlers()
}
}
}
func (r *Reticulum) Stop() error {
debug.Log(debug.DebugError, "Stopping Reticulum...")
r.refreshOnce.Do(func() { close(r.refreshStop) })
for _, buf := range r.buffers {
if err := buf.Close(); err != nil {
debug.Log(debug.DebugCritical, "Error closing buffer", "error", err)
}
}
for _, ch := range r.channels {
if err := ch.Close(); err != nil {
debug.Log(debug.DebugCritical, "Error closing channel", "error", err)
}
}
for _, iface := range r.interfaces {
if err := iface.Stop(); err != nil {
debug.Log(debug.DebugCritical, "Error stopping interface", "name", iface.GetName(), "error", err)
}
}
if err := r.transport.Close(); err != nil {
return fmt.Errorf("failed to close transport: %v", err)
}
debug.Log(debug.DebugError, "Reticulum stopped successfully")
return nil
}
// AnnounceHandler records announces and decodes optional msgpack node metadata from app_data.
type AnnounceHandler struct {
aspectFilter []string
reticulum *Reticulum
}
// NewAnnounceHandler builds a handler that accepts all aspects when aspectFilter is ["*"].
func NewAnnounceHandler(r *Reticulum, aspectFilter []string) *AnnounceHandler {
return &AnnounceHandler{
aspectFilter: aspectFilter,
reticulum: r,
}
}
func (h *AnnounceHandler) AspectFilter() []string {
return h.aspectFilter
}
func (h *AnnounceHandler) ReceivedAnnounce(destHash []byte, id any, appData []byte, hops uint8) error {
debug.Log(debug.DebugInfo, "Received announce", "hash", fmt.Sprintf("%x", destHash))
debug.Log(debug.DebugPackets, "Raw announce data", "data", fmt.Sprintf("%x", appData))
debug.Log(debug.DebugInfo, "MAIN HANDLER: Received announce", "hash", fmt.Sprintf("%x", destHash), "appData_len", len(appData))
var isNode bool
var nodeEnabled bool
var nodeTimestamp int64
var nodeMaxSize int16
if len(appData) > 0 {
var decoded any
if err := msgpack.Unmarshal(appData, &decoded); err == nil {
if enabled, ts, maxSize, ok := parseNodeStatusAppData(decoded); ok {
isNode = true
nodeEnabled = enabled
nodeTimestamp = ts
nodeMaxSize = maxSize
debug.Log(
debug.DebugInfo,
"Parsed node status appData",
"enabled", nodeEnabled,
"timestamp", nodeTimestamp,
"maxsize_kb", nodeMaxSize,
)
}
if nodeName, ok := extractNodeName(decoded); ok {
nodeName = ClampDisplayName(nodeName, "")
if nodeName != "" {
debug.Log(debug.DebugInfo, "Parsed node name", "name", nodeName)
debug.Log(debug.DebugInfo, "Announced node", "name", nodeName)
}
} else if !isNode {
debug.Log(debug.DebugInfo, "Parsed structured appData", "value", fmt.Sprintf("%v", decoded))
}
} else {
if utf8.Valid(appData) {
nodeName := ClampDisplayName(string(appData), "")
if nodeName != "" {
debug.Log(debug.DebugInfo, "Raw node name", "name", nodeName)
debug.Log(debug.DebugInfo, "Announced node", "name", nodeName)
} else {
debug.Log(debug.DebugInfo, "Raw appData is empty text after trim")
}
} else {
debug.Log(debug.DebugInfo, "Binary appData (non-text)", "hex", fmt.Sprintf("%x", appData))
}
}
} else {
debug.Log(debug.DebugInfo, "No appData (empty announce)")
}
if announcedID, ok := id.(*identity.Identity); ok {
debug.Log(debug.DebugAll, "Identity details")
debug.Log(debug.DebugAll, "Identity hash", "hash", announcedID.GetHexHash())
debug.Log(debug.DebugAll, "Identity public key", "key", fmt.Sprintf("%x", announcedID.GetPublicKey()))
if ratchetPub := identity.GetRatchet(destHash); len(ratchetPub) > 0 {
debug.Log(debug.DebugAll, "Announced ratchet ID", "id", fmt.Sprintf("%x", announcedID.GetRatchetID(ratchetPub)))
}
recordType := "peer"
if isNode {
recordType = "node"
debug.Log(debug.DebugInfo, "Storing node in announce history", "enabled", nodeEnabled, "timestamp", nodeTimestamp, "maxsize", fmt.Sprintf("%dKB", nodeMaxSize))
}
h.reticulum.announceHistoryMu.Lock()
h.reticulum.announceHistory[announcedID.GetHexHash()] = announceRecord{
timestamp: time.Now().Unix(),
appData: appData,
}
h.reticulum.announceHistoryMu.Unlock()
debug.Log(debug.DebugVerbose, "Stored announce in history", "type", recordType, "identity", announcedID.GetHexHash())
}
return nil
}
func (h *AnnounceHandler) ReceivePathResponses() bool {
return true
}
func extractNodeName(decoded any) (string, bool) {
if decoded == nil {
return "", false
}
if str, ok := msgpackString(decoded); ok {
str = strings.TrimSpace(str)
return str, str != ""
}
arr, ok := decoded.([]any)
if !ok || len(arr) == 0 {
return "", false
}
for _, v := range arr {
if str, ok := msgpackString(v); ok {
str = strings.TrimSpace(str)
if str != "" {
return str, true
}
}
}
return "", false
}
func parseNodeStatusAppData(decoded any) (enabled bool, timestamp int64, maxSizeKB int16, ok bool) {
arr, ok := decoded.([]any)
if !ok || len(arr) < 3 {
return false, 0, 0, false
}
enabledVal, ok := arr[0].(bool)
if !ok {
return false, 0, 0, false
}
timestampVal, ok := msgpackInt64(arr[1])
if !ok {
return false, 0, 0, false
}
maxSizeVal, ok := msgpackInt64(arr[2])
if !ok {
return false, 0, 0, false
}
if maxSizeVal < math.MinInt16 || maxSizeVal > math.MaxInt16 {
return false, 0, 0, false
}
return enabledVal, timestampVal, int16(maxSizeVal), true
}
func msgpackString(v any) (string, bool) {
switch val := v.(type) {
case string:
return val, true
case []byte:
if utf8.Valid(val) {
return string(val), true
}
return "", false
default:
return "", false
}
}
func msgpackInt64(v any) (int64, bool) {
switch val := v.(type) {
case int:
return int64(val), true
case int8:
return int64(val), true
case int16:
return int64(val), true
case int32:
return int64(val), true
case int64:
return val, true
case uint:
return int64(val), true // #nosec G115
case uint8:
return int64(val), true
case uint16:
return int64(val), true
case uint32:
return int64(val), true
case uint64:
return int64(val), true // #nosec G115
default:
return 0, false
}
}
func (r *Reticulum) GetDestination() *destination.Destination {
return r.destination
}
// createNodeAppData encodes msgpack [enabled, timestamp_unix, max_kb] for structured node announces.
func (r *Reticulum) createNodeAppData() []byte {
appData := []byte{0x93}
if r.nodeEnabled {
appData = append(appData, 0xc3)
} else {
appData = append(appData, 0xc2)
}
r.nodeTimestamp = time.Now().Unix()
appData = append(appData, 0xd2)
timeBytes := make([]byte, 4)
binary.BigEndian.PutUint32(timeBytes, uint32(r.nodeTimestamp)) // #nosec G115
appData = append(appData, timeBytes...)
appData = append(appData, 0xd1)
sizeBytes := make([]byte, 2)
binary.BigEndian.PutUint16(sizeBytes, uint16(r.maxTransferSize)) // #nosec G115
appData = append(appData, sizeBytes...)
debug.Log(debug.DebugAll, "Created node appData", "enable", r.nodeEnabled, "timestamp", r.nodeTimestamp, "maxsize", r.maxTransferSize, "data", fmt.Sprintf("%x", appData))
return appData
}
func (r *Reticulum) onLinkEstablished(linkInterface any) {
startTime := time.Now()
debug.Log(debug.DebugInfo, "Link established callback called", "interface_type", fmt.Sprintf("%T", linkInterface))
l, ok := linkInterface.(*link.Link)
if !ok {
debug.Log(debug.DebugError, "Invalid link interface type, expected *link.Link")
return
}
r.linksMutex.Lock()
linkIDStr := fmt.Sprintf("%x", l.GetLinkID())
r.establishedLinks[linkIDStr] = l
r.linksMutex.Unlock()
debug.Log(debug.DebugInfo, "Link established successfully", "link_id", linkIDStr, "rtt", l.GetRTT(), "elapsed", time.Since(startTime).Seconds())
l.SetPacketCallback(func(data []byte, pkt *packet.Packet) {
r.handleLinkPacket(l, data, pkt)
})
l.SetLinkClosedCallback(func(closed *link.Link) {
r.linksMutex.Lock()
delete(r.establishedLinks, fmt.Sprintf("%x", closed.GetLinkID()))
r.linksMutex.Unlock()
})
}
func (r *Reticulum) registerStaticHandlers() {
r.syncPageHandlers()
r.syncFileHandlers()
debug.Log(debug.DebugInfo, "Static page and file handlers registered")
}
func (r *Reticulum) syncPageHandlers() {
r.staticPagesMu.Lock()
defer r.staticPagesMu.Unlock()
pagesDir := r.pagesPath
if err := os.MkdirAll(pagesDir, 0750); err != nil { // #nosec G301
debug.Log(debug.DebugError, "Failed to create pages directory", "error", err)
return
}
current := make(map[string]struct{})
walkErr := filepath.Walk(pagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(pagesDir, path)
if err != nil {
return err
}
requestPath := "/page/" + strings.ReplaceAll(relPath, "\\", "/")
current[requestPath] = struct{}{}
debug.Log(debug.DebugVerbose, "Registering page handler", "path", requestPath, "file", path)
if err := r.destination.RegisterRequestHandler(
requestPath,
func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
return r.servePage(path, data, requestID, linkID, remoteIdentity, requestedAt)
},
destination.AllowAll,
nil,
); err != nil {
debug.Log(debug.DebugError, "Failed to register page handler", "path", requestPath, "error", err)
return err
}
return nil
})
if walkErr != nil {
debug.Log(debug.DebugError, "Failed to walk pages directory", "error", walkErr)
return
}
debug.Log(debug.DebugInfo, "Pages directory synced", "handlers", len(current))
for path := range r.registeredPagePaths {
if _, keep := current[path]; !keep {
if r.destination.DeregisterRequestHandler(path) {
debug.Log(debug.DebugInfo, "Deregistered removed page handler", "path", path)
}
}
}
r.registeredPagePaths = current
r.syncBuiltInPageStatsHandler()
}
func (r *Reticulum) syncFileHandlers() {
r.staticFilesMu.Lock()
defer r.staticFilesMu.Unlock()
filesDir := r.filesPath
if err := os.MkdirAll(filesDir, 0750); err != nil { // #nosec G301
debug.Log(debug.DebugError, "Failed to create files directory", "error", err)
return
}
current := make(map[string]struct{})
walkErr := filepath.Walk(filesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
requestPath := "/file/" + strings.ReplaceAll(relPath, "\\", "/")
current[requestPath] = struct{}{}
debug.Log(debug.DebugVerbose, "Registering file handler", "path", requestPath, "file", path)
if err := r.destination.RegisterRequestHandlerAny(
requestPath,
func(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) any {
return r.serveFile(path, data, requestID, linkID, remoteIdentity, requestedAt)
},
destination.AllowAll,
nil,
); err != nil {
debug.Log(debug.DebugError, "Failed to register file handler", "path", requestPath, "error", err)
return err
}
return nil
})
if walkErr != nil {
debug.Log(debug.DebugError, "Failed to walk files directory", "error", walkErr)
return
}
debug.Log(debug.DebugInfo, "Files directory synced", "handlers", len(current))
for path := range r.registeredFilePaths {
if _, keep := current[path]; !keep {
if r.destination.DeregisterRequestHandler(path) {
debug.Log(debug.DebugInfo, "Deregistered removed file handler", "path", path)
}
}
}
r.registeredFilePaths = current
}
func (r *Reticulum) servePage(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) []byte {
debug.Log(debug.DebugInfo, "Serving page", "path", path, "request_id", fmt.Sprintf("%x", requestID))
var joined string
if after, ok := strings.CutPrefix(path, "/page/"); ok {
joined = filepath.Join(r.pagesPath, after)
} else {
joined = filepath.Join(r.pagesPath, path)
}
filePath, ok := resolveJailedPath(r.pagesPath, joined)
if !ok {
debug.Log(debug.DebugError, "Path traversal attempt detected", "path", path)
return []byte(">Request Not Allowed\n\nYou are not authorized to access this resource.")
}
content, err := dynamicpage.ReadOrExecute(filePath, data, linkID, remoteIdentity)
if err != nil {
debug.Log(debug.DebugError, "Failed to read page", "path", filePath, "error", err)
return []byte(">Page Not Found\n\nThe requested page could not be found.")
}
r.recordPageView(path)
debug.Log(debug.DebugVerbose, "Page served successfully", "path", path, "size", len(content))
return content
}
func (r *Reticulum) serveFile(path string, data []byte, requestID []byte, linkID []byte, remoteIdentity *identity.Identity, requestedAt int64) any {
debug.Log(debug.DebugInfo, "Serving file", "path", path, "request_id", fmt.Sprintf("%x", requestID))
var joined string
if after, ok := strings.CutPrefix(path, "/file/"); ok {
joined = filepath.Join(r.filesPath, after)
} else {
joined = filepath.Join(r.filesPath, path)
}
filePath, ok := resolveJailedPath(r.filesPath, joined)
if !ok {
debug.Log(debug.DebugError, "Path traversal attempt detected", "path", path)
return []byte(">Request Not Allowed\n\nYou are not authorized to access this resource.")
}
file, err := os.Open(filePath) // #nosec G304 -- filePath resolved and jail-validated by resolveJailedPath
if err != nil {
debug.Log(debug.DebugError, "Failed to open file", "path", filePath, "error", err)
return []byte(">File Not Found\n\nThe requested file could not be found.")
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
debug.Log(debug.DebugError, "Failed to read file", "path", filePath, "error", err)
return []byte(">Error Reading File\n\nAn error occurred while reading the file.")
}
fileName := filepath.Base(filePath)
debug.Log(debug.DebugVerbose, "File served successfully", "path", path, "size", len(content), "name", fileName)
return []any{fileName, content}
}
func (r *Reticulum) handleLinkPacket(l *link.Link, data []byte, pkt *packet.Packet) {
debug.Log(debug.DebugInfo, "Received packet on link", "link_id", fmt.Sprintf("%x", l.GetLinkID()), "data_len", len(data))
if len(data) < 16 {
debug.Log(debug.DebugError, "Request too short")
return
}
requestID := data[:16]
requestPath := string(data[16:])
debug.Log(debug.DebugInfo, "Processing request", "path", requestPath, "request_id", fmt.Sprintf("%x", requestID))
r.destination.HandleRequest(requestPath, nil, requestID, l.GetLinkID(), nil, time.Now().Unix())
}
func destPrivateRatchetPath(configRoot string, dest *destination.Destination, ident *identity.Identity) string {
dir := filepath.Join(configRoot, "storage", "ratchets")
path := filepath.Join(dir, hex.EncodeToString(dest.GetHash()))
if ident == nil {
return path
}
old := filepath.Join(dir, ident.GetHexHash())
if path == old {
return path
}
if _, err := os.Stat(path); err == nil {
return path
}
if _, err := os.Stat(old); err != nil {
return path
}
if err := os.Rename(old, path); err != nil {
debug.Log(debug.DebugError, "Failed to rename ratchet file to destination hash", "error", err, "from", old, "to", path)
return old
}
return path
}