mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-18 03:56:58 +00:00
feat(security): add manifest signing, TLS TOFU, refresh token migration
- Invalidate plaintext refresh tokens (migration 019) - Add `--sign` flag to `orama build` for rootwallet manifest signing - Add `--ca-fingerprint` TOFU verification for production joins/invites - Save cluster secrets from join (RQLite auth, Olric key, IPFS peers) - Add RQLite auth config fields
This commit is contained in:
parent
a0468461ab
commit
fd87eec476
4
migrations/019_invalidate_plaintext_refresh_tokens.sql
Normal file
4
migrations/019_invalidate_plaintext_refresh_tokens.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Invalidate all existing refresh tokens.
|
||||||
|
-- Tokens were stored in plaintext; the application now stores SHA-256 hashes.
|
||||||
|
-- Users will need to re-authenticate (tokens have 30-day expiry anyway).
|
||||||
|
UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE revoked_at IS NULL;
|
||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -106,6 +107,14 @@ func (b *Builder) createArchive(outputPath string, manifest *Manifest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add manifest.sig if it exists (created by --sign)
|
||||||
|
sigPath := filepath.Join(b.tmpDir, "manifest.sig")
|
||||||
|
if _, err := os.Stat(sigPath); err == nil {
|
||||||
|
if err := addFileToTar(tw, sigPath, "manifest.sig"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Print summary
|
// Print summary
|
||||||
fmt.Printf(" bin/: %d binaries\n", len(manifest.Checksums))
|
fmt.Printf(" bin/: %d binaries\n", len(manifest.Checksums))
|
||||||
fmt.Printf(" systemd/: namespace templates\n")
|
fmt.Printf(" systemd/: namespace templates\n")
|
||||||
@ -119,6 +128,46 @@ func (b *Builder) createArchive(outputPath string, manifest *Manifest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signManifest signs the manifest hash using rootwallet CLI.
|
||||||
|
// Produces manifest.sig containing the hex-encoded EVM signature.
|
||||||
|
func (b *Builder) signManifest(manifest *Manifest) error {
|
||||||
|
fmt.Printf("\nSigning manifest with rootwallet...\n")
|
||||||
|
|
||||||
|
// Serialize manifest deterministically (compact JSON, sorted keys via json.Marshal)
|
||||||
|
manifestData, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the manifest JSON
|
||||||
|
hash := sha256.Sum256(manifestData)
|
||||||
|
hashHex := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// Call rw sign <hash> --chain evm
|
||||||
|
cmd := exec.Command("rw", "sign", hashHex, "--chain", "evm")
|
||||||
|
var stdout, stderr strings.Builder
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("rw sign failed: %w\n%s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := strings.TrimSpace(stdout.String())
|
||||||
|
if signature == "" {
|
||||||
|
return fmt.Errorf("rw sign produced empty signature")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write signature file
|
||||||
|
sigPath := filepath.Join(b.tmpDir, "manifest.sig")
|
||||||
|
if err := os.WriteFile(sigPath, []byte(signature), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write manifest.sig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Manifest signed (SHA256: %s...)\n", hashHex[:16])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// addDirToTar adds all files in a directory to the tar archive under the given prefix.
|
// addDirToTar adds all files in a directory to the tar archive under the given prefix.
|
||||||
func addDirToTar(tw *tar.Writer, srcDir, prefix string) error {
|
func addDirToTar(tw *tar.Writer, srcDir, prefix string) error {
|
||||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
|||||||
@ -117,7 +117,14 @@ func (b *Builder) Build() error {
|
|||||||
return fmt.Errorf("failed to generate manifest: %w", err)
|
return fmt.Errorf("failed to generate manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 11: Create archive
|
// Step 11: Sign manifest (optional)
|
||||||
|
if b.flags.Sign {
|
||||||
|
if err := b.signManifest(manifest); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign manifest: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 12: Create archive
|
||||||
outputPath := b.flags.Output
|
outputPath := b.flags.Output
|
||||||
if outputPath == "" {
|
if outputPath == "" {
|
||||||
outputPath = fmt.Sprintf("/tmp/orama-%s-linux-%s.tar.gz", b.version, b.flags.Arch)
|
outputPath = fmt.Sprintf("/tmp/orama-%s-linux-%s.tar.gz", b.version, b.flags.Arch)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ type Flags struct {
|
|||||||
Arch string
|
Arch string
|
||||||
Output string
|
Output string
|
||||||
Verbose bool
|
Verbose bool
|
||||||
|
Sign bool // Sign the archive manifest with rootwallet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle is the entry point for the build command.
|
// Handle is the entry point for the build command.
|
||||||
@ -42,6 +43,7 @@ func parseFlags(args []string) (*Flags, error) {
|
|||||||
fs.StringVar(&flags.Arch, "arch", "amd64", "Target architecture (amd64, arm64)")
|
fs.StringVar(&flags.Arch, "arch", "amd64", "Target architecture (amd64, arm64)")
|
||||||
fs.StringVar(&flags.Output, "output", "", "Output archive path (default: /tmp/orama-<version>-linux-<arch>.tar.gz)")
|
fs.StringVar(&flags.Output, "output", "", "Output archive path (default: /tmp/orama-<version>-linux-<arch>.tar.gz)")
|
||||||
fs.BoolVar(&flags.Verbose, "verbose", false, "Verbose output")
|
fs.BoolVar(&flags.Verbose, "verbose", false, "Verbose output")
|
||||||
|
fs.BoolVar(&flags.Sign, "sign", false, "Sign the manifest with rootwallet (requires rw in PATH)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -29,6 +29,7 @@ type Flags struct {
|
|||||||
|
|
||||||
// Security flags
|
// Security flags
|
||||||
SkipFirewall bool // Skip UFW firewall setup (for users who manage their own firewall)
|
SkipFirewall bool // Skip UFW firewall setup (for users who manage their own firewall)
|
||||||
|
CAFingerprint string // SHA-256 fingerprint of server TLS cert for TOFU verification
|
||||||
|
|
||||||
// Anyone flags
|
// Anyone flags
|
||||||
AnyoneClient bool // Run Anyone as client-only (SOCKS5 proxy on port 9050, no relay)
|
AnyoneClient bool // Run Anyone as client-only (SOCKS5 proxy on port 9050, no relay)
|
||||||
@ -74,6 +75,7 @@ func ParseFlags(args []string) (*Flags, error) {
|
|||||||
|
|
||||||
// Security flags
|
// Security flags
|
||||||
fs.BoolVar(&flags.SkipFirewall, "skip-firewall", false, "Skip UFW firewall setup (for users who manage their own firewall)")
|
fs.BoolVar(&flags.SkipFirewall, "skip-firewall", false, "Skip UFW firewall setup (for users who manage their own firewall)")
|
||||||
|
fs.StringVar(&flags.CAFingerprint, "ca-fingerprint", "", "SHA-256 fingerprint of server TLS cert (from orama invite output)")
|
||||||
|
|
||||||
// Anyone flags
|
// Anyone flags
|
||||||
fs.BoolVar(&flags.AnyoneClient, "anyone-client", false, "Install Anyone as client-only (SOCKS5 proxy on port 9050, no relay)")
|
fs.BoolVar(&flags.AnyoneClient, "anyone-client", false, "Install Anyone as client-only (SOCKS5 proxy on port 9050, no relay)")
|
||||||
|
|||||||
@ -2,8 +2,12 @@ package install
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -366,12 +370,35 @@ func (o *Orchestrator) callJoinEndpoint(wgPubKey string) (*joinhandlers.JoinResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimRight(o.flags.JoinAddress, "/") + "/v1/internal/join"
|
url := strings.TrimRight(o.flags.JoinAddress, "/") + "/v1/internal/join"
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{}
|
||||||
|
if o.flags.CAFingerprint != "" {
|
||||||
|
// TOFU: verify the server's TLS cert fingerprint matches the one from the invite
|
||||||
|
expectedFP, err := hex.DecodeString(o.flags.CAFingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid --ca-fingerprint: must be hex-encoded SHA-256: %w", err)
|
||||||
|
}
|
||||||
|
tlsConfig.InsecureSkipVerify = true
|
||||||
|
tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
if len(rawCerts) == 0 {
|
||||||
|
return fmt.Errorf("server presented no TLS certificates")
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(rawCerts[0])
|
||||||
|
if !bytes.Equal(hash[:], expectedFP) {
|
||||||
|
return fmt.Errorf("TLS certificate fingerprint mismatch: expected %s, got %x (possible MITM attack)",
|
||||||
|
o.flags.CAFingerprint, hash[:])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No fingerprint provided — fall back to insecure for backward compatibility
|
||||||
|
tlsConfig.InsecureSkipVerify = true
|
||||||
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: tlsConfig,
|
||||||
InsecureSkipVerify: true, // Self-signed certs during initial setup
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,6 +446,40 @@ func (o *Orchestrator) saveSecretsFromJoinResponse(resp *joinhandlers.JoinRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write API key HMAC secret
|
||||||
|
if resp.APIKeyHMACSecret != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "api-key-hmac-secret"), []byte(resp.APIKeyHMACSecret), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write api-key-hmac-secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write RQLite password and generate auth JSON file
|
||||||
|
if resp.RQLitePassword != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "rqlite-password"), []byte(resp.RQLitePassword), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write rqlite-password: %w", err)
|
||||||
|
}
|
||||||
|
// Also generate the auth JSON file that rqlited uses with -auth flag
|
||||||
|
authJSON := fmt.Sprintf(`[{"username": "orama", "password": "%s", "perms": ["all"]}]`, resp.RQLitePassword)
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "rqlite-auth.json"), []byte(authJSON), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write rqlite-auth.json: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write Olric encryption key
|
||||||
|
if resp.OlricEncryptionKey != "" {
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "olric-encryption-key"), []byte(resp.OlricEncryptionKey), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write olric-encryption-key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write IPFS Cluster trusted peer IDs
|
||||||
|
if len(resp.IPFSClusterPeerIDs) > 0 {
|
||||||
|
content := strings.Join(resp.IPFSClusterPeerIDs, "\n") + "\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(secretsDir, "ipfs-cluster-trusted-peers"), []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write ipfs-cluster-trusted-peers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,9 +3,12 @@ package invite
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@ -59,13 +62,43 @@ func Handle(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get TLS certificate fingerprint for TOFU verification
|
||||||
|
certFingerprint := getTLSCertFingerprint(domain)
|
||||||
|
|
||||||
// Print the invite command
|
// Print the invite command
|
||||||
fmt.Printf("\nInvite token created (expires in %s)\n\n", expiry)
|
fmt.Printf("\nInvite token created (expires in %s)\n\n", expiry)
|
||||||
fmt.Printf("Run this on the new node:\n\n")
|
fmt.Printf("Run this on the new node:\n\n")
|
||||||
|
if certFingerprint != "" {
|
||||||
|
fmt.Printf(" sudo orama install --join https://%s --token %s --ca-fingerprint %s --vps-ip <NEW_NODE_IP> --nameserver\n\n", domain, token, certFingerprint)
|
||||||
|
} else {
|
||||||
fmt.Printf(" sudo orama install --join https://%s --token %s --vps-ip <NEW_NODE_IP> --nameserver\n\n", domain, token)
|
fmt.Printf(" sudo orama install --join https://%s --token %s --vps-ip <NEW_NODE_IP> --nameserver\n\n", domain, token)
|
||||||
|
}
|
||||||
fmt.Printf("Replace <NEW_NODE_IP> with the new node's public IP address.\n")
|
fmt.Printf("Replace <NEW_NODE_IP> with the new node's public IP address.\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTLSCertFingerprint connects to the domain over TLS and returns the
|
||||||
|
// SHA-256 fingerprint of the leaf certificate. Returns empty string on failure.
|
||||||
|
func getTLSCertFingerprint(domain string) string {
|
||||||
|
conn, err := tls.DialWithDialer(
|
||||||
|
&net.Dialer{Timeout: 5 * time.Second},
|
||||||
|
"tcp",
|
||||||
|
domain+":443",
|
||||||
|
&tls.Config{InsecureSkipVerify: true},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
certs := conn.ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256(certs[0].Raw)
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
// readNodeDomain reads the domain from the node config file
|
// readNodeDomain reads the domain from the node config file
|
||||||
func readNodeDomain() (string, error) {
|
func readNodeDomain() (string, error) {
|
||||||
configPath := "/opt/orama/.orama/configs/node.yaml"
|
configPath := "/opt/orama/.orama/configs/node.yaml"
|
||||||
|
|||||||
@ -22,6 +22,13 @@ type DatabaseConfig struct {
|
|||||||
NodeCACert string `yaml:"node_ca_cert"` // Path to CA certificate (optional, uses system CA if not set)
|
NodeCACert string `yaml:"node_ca_cert"` // Path to CA certificate (optional, uses system CA if not set)
|
||||||
NodeNoVerify bool `yaml:"node_no_verify"` // Skip certificate verification (for testing/self-signed certs)
|
NodeNoVerify bool `yaml:"node_no_verify"` // Skip certificate verification (for testing/self-signed certs)
|
||||||
|
|
||||||
|
// RQLite HTTP Basic Auth credentials.
|
||||||
|
// When RQLiteAuthFile is set, rqlited is launched with `-auth <file>`.
|
||||||
|
// Username/password are embedded in all client DSNs (harmless when auth not enforced).
|
||||||
|
RQLiteUsername string `yaml:"rqlite_username"`
|
||||||
|
RQLitePassword string `yaml:"rqlite_password"`
|
||||||
|
RQLiteAuthFile string `yaml:"rqlite_auth_file"` // Path to RQLite auth JSON file. Empty = auth not enforced.
|
||||||
|
|
||||||
// Raft tuning (passed through to rqlited CLI flags).
|
// Raft tuning (passed through to rqlited CLI flags).
|
||||||
// Higher defaults than rqlited's 1s suit WireGuard latency.
|
// Higher defaults than rqlited's 1s suit WireGuard latency.
|
||||||
RaftElectionTimeout time.Duration `yaml:"raft_election_timeout"` // default: 5s
|
RaftElectionTimeout time.Duration `yaml:"raft_election_timeout"` // default: 5s
|
||||||
|
|||||||
@ -31,9 +31,10 @@ type Backend struct {
|
|||||||
healthy bool
|
healthy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBackend creates a new RQLite backend
|
// NewBackend creates a new RQLite backend.
|
||||||
func NewBackend(dsn string, refreshRate time.Duration, logger *zap.Logger) (*Backend, error) {
|
// Optional username/password enable HTTP basic auth for RQLite connections.
|
||||||
client, err := NewRQLiteClient(dsn, logger)
|
func NewBackend(dsn string, refreshRate time.Duration, logger *zap.Logger, username, password string) (*Backend, error) {
|
||||||
|
client, err := NewRQLiteClient(dsn, logger, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create RQLite client: %w", err)
|
return nil, fmt.Errorf("failed to create RQLite client: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import (
|
|||||||
// RQLiteClient is a simple HTTP client for RQLite
|
// RQLiteClient is a simple HTTP client for RQLite
|
||||||
type RQLiteClient struct {
|
type RQLiteClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
username string // HTTP basic auth username (empty = no auth)
|
||||||
|
password string // HTTP basic auth password
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
@ -32,10 +34,13 @@ type QueryResult struct {
|
|||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRQLiteClient creates a new RQLite HTTP client
|
// NewRQLiteClient creates a new RQLite HTTP client.
|
||||||
func NewRQLiteClient(dsn string, logger *zap.Logger) (*RQLiteClient, error) {
|
// Optional username/password enable HTTP basic auth on all requests.
|
||||||
|
func NewRQLiteClient(dsn string, logger *zap.Logger, username, password string) (*RQLiteClient, error) {
|
||||||
return &RQLiteClient{
|
return &RQLiteClient{
|
||||||
baseURL: dsn,
|
baseURL: dsn,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
@ -65,6 +70,9 @@ func (c *RQLiteClient) Query(ctx context.Context, query string, args ...interfac
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.username != "" && c.password != "" {
|
||||||
|
req.SetBasicAuth(c.username, c.password)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -42,6 +42,8 @@ func parseConfig(c *caddy.Controller) (*RQLitePlugin, error) {
|
|||||||
refreshRate = 10 * time.Second
|
refreshRate = 10 * time.Second
|
||||||
cacheTTL = 30 * time.Second
|
cacheTTL = 30 * time.Second
|
||||||
cacheSize = 10000
|
cacheSize = 10000
|
||||||
|
rqliteUsername string
|
||||||
|
rqlitePassword string
|
||||||
zones []string
|
zones []string
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -90,6 +92,18 @@ func parseConfig(c *caddy.Controller) (*RQLitePlugin, error) {
|
|||||||
}
|
}
|
||||||
cacheSize = size
|
cacheSize = size
|
||||||
|
|
||||||
|
case "username":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
rqliteUsername = c.Val()
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
if !c.NextArg() {
|
||||||
|
return nil, c.ArgErr()
|
||||||
|
}
|
||||||
|
rqlitePassword = c.Val()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, c.Errf("unknown property '%s'", c.Val())
|
return nil, c.Errf("unknown property '%s'", c.Val())
|
||||||
}
|
}
|
||||||
@ -101,7 +115,7 @@ func parseConfig(c *caddy.Controller) (*RQLitePlugin, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create backend
|
// Create backend
|
||||||
backend, err := NewBackend(dsn, refreshRate, logger)
|
backend, err := NewBackend(dsn, refreshRate, logger, rqliteUsername, rqlitePassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create backend: %w", err)
|
return nil, fmt.Errorf("failed to create backend: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package production
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@ -239,8 +240,15 @@ func (cg *ConfigGenerator) GenerateGatewayConfig(peerAddresses []string, enableH
|
|||||||
return templates.RenderGatewayConfig(data)
|
return templates.RenderGatewayConfig(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOlricConfig generates Olric configuration
|
// GenerateOlricConfig generates Olric configuration.
|
||||||
|
// Reads the Olric encryption key from secrets if available.
|
||||||
func (cg *ConfigGenerator) GenerateOlricConfig(serverBindAddr string, httpPort int, memberlistBindAddr string, memberlistPort int, memberlistEnv string, advertiseAddr string, peers []string) (string, error) {
|
func (cg *ConfigGenerator) GenerateOlricConfig(serverBindAddr string, httpPort int, memberlistBindAddr string, memberlistPort int, memberlistEnv string, advertiseAddr string, peers []string) (string, error) {
|
||||||
|
// Read encryption key from secrets if available
|
||||||
|
encryptionKey := ""
|
||||||
|
if data, err := os.ReadFile(filepath.Join(cg.oramaDir, "secrets", "olric-encryption-key")); err == nil {
|
||||||
|
encryptionKey = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
data := templates.OlricConfigData{
|
data := templates.OlricConfigData{
|
||||||
ServerBindAddr: serverBindAddr,
|
ServerBindAddr: serverBindAddr,
|
||||||
HTTPPort: httpPort,
|
HTTPPort: httpPort,
|
||||||
@ -249,6 +257,7 @@ func (cg *ConfigGenerator) GenerateOlricConfig(serverBindAddr string, httpPort i
|
|||||||
MemberlistEnvironment: memberlistEnv,
|
MemberlistEnvironment: memberlistEnv,
|
||||||
MemberlistAdvertiseAddr: advertiseAddr,
|
MemberlistAdvertiseAddr: advertiseAddr,
|
||||||
Peers: peers,
|
Peers: peers,
|
||||||
|
EncryptionKey: encryptionKey,
|
||||||
}
|
}
|
||||||
return templates.RenderOlricConfig(data)
|
return templates.RenderOlricConfig(data)
|
||||||
}
|
}
|
||||||
@ -323,6 +332,137 @@ func (sg *SecretGenerator) EnsureClusterSecret() (string, error) {
|
|||||||
return secret, nil
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureRQLiteAuth generates the RQLite auth credentials and JSON auth file.
|
||||||
|
// Returns (username, password). The auth JSON file is written to secrets/rqlite-auth.json.
|
||||||
|
func (sg *SecretGenerator) EnsureRQLiteAuth() (string, string, error) {
|
||||||
|
passwordPath := filepath.Join(sg.oramaDir, "secrets", "rqlite-password")
|
||||||
|
authFilePath := filepath.Join(sg.oramaDir, "secrets", "rqlite-auth.json")
|
||||||
|
secretDir := filepath.Dir(passwordPath)
|
||||||
|
username := "orama"
|
||||||
|
|
||||||
|
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(secretDir, 0700); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to set secrets directory permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read existing password
|
||||||
|
var password string
|
||||||
|
if data, err := os.ReadFile(passwordPath); err == nil {
|
||||||
|
password = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new password if needed
|
||||||
|
if password == "" {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to generate RQLite password: %w", err)
|
||||||
|
}
|
||||||
|
password = hex.EncodeToString(bytes)
|
||||||
|
|
||||||
|
if err := os.WriteFile(passwordPath, []byte(password), 0600); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to save RQLite password: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureSecretFilePermissions(passwordPath); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always regenerate the auth JSON file to ensure consistency
|
||||||
|
authJSON := fmt.Sprintf(`[{"username": "%s", "password": "%s", "perms": ["all"]}]`, username, password)
|
||||||
|
if err := os.WriteFile(authFilePath, []byte(authJSON), 0600); err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to save RQLite auth file: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureSecretFilePermissions(authFilePath); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return username, password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOlricEncryptionKey gets or generates a 32-byte encryption key for Olric memberlist gossip.
|
||||||
|
// The key is stored as base64 on disk and returned as base64 (what Olric expects).
|
||||||
|
func (sg *SecretGenerator) EnsureOlricEncryptionKey() (string, error) {
|
||||||
|
secretPath := filepath.Join(sg.oramaDir, "secrets", "olric-encryption-key")
|
||||||
|
secretDir := filepath.Dir(secretPath)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(secretDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to set secrets directory permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read existing key
|
||||||
|
if data, err := os.ReadFile(secretPath); err == nil {
|
||||||
|
key := strings.TrimSpace(string(data))
|
||||||
|
if key != "" {
|
||||||
|
if err := ensureSecretFilePermissions(secretPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new 32-byte key, base64 encoded
|
||||||
|
keyBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(keyBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate Olric encryption key: %w", err)
|
||||||
|
}
|
||||||
|
key := base64.StdEncoding.EncodeToString(keyBytes)
|
||||||
|
|
||||||
|
if err := os.WriteFile(secretPath, []byte(key), 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save Olric encryption key: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureSecretFilePermissions(secretPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAPIKeyHMACSecret gets or generates the HMAC secret used to hash API keys.
|
||||||
|
// The secret is a 32-byte random value stored as 64 hex characters.
|
||||||
|
func (sg *SecretGenerator) EnsureAPIKeyHMACSecret() (string, error) {
|
||||||
|
secretPath := filepath.Join(sg.oramaDir, "secrets", "api-key-hmac-secret")
|
||||||
|
secretDir := filepath.Dir(secretPath)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(secretDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to set secrets directory permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read existing secret
|
||||||
|
if data, err := os.ReadFile(secretPath); err == nil {
|
||||||
|
secret := strings.TrimSpace(string(data))
|
||||||
|
if len(secret) == 64 {
|
||||||
|
if err := ensureSecretFilePermissions(secretPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new secret (32 bytes = 64 hex chars)
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate API key HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
secret := hex.EncodeToString(bytes)
|
||||||
|
|
||||||
|
if err := os.WriteFile(secretPath, []byte(secret), 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to save API key HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
if err := ensureSecretFilePermissions(secretPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureSecretFilePermissions(secretPath string) error {
|
func ensureSecretFilePermissions(secretPath string) error {
|
||||||
if err := os.Chmod(secretPath, 0600); err != nil {
|
if err := os.Chmod(secretPath, 0600); err != nil {
|
||||||
return fmt.Errorf("failed to set permissions on %s: %w", secretPath, err)
|
return fmt.Errorf("failed to set permissions on %s: %w", secretPath, err)
|
||||||
|
|||||||
@ -98,7 +98,12 @@ func (fp *FirewallProvisioner) GenerateRules() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Allow all traffic from WireGuard subnet (inter-node encrypted traffic)
|
// Allow all traffic from WireGuard subnet (inter-node encrypted traffic)
|
||||||
rules = append(rules, "ufw allow from 10.0.0.0/8")
|
rules = append(rules, "ufw allow from 10.0.0.0/24")
|
||||||
|
|
||||||
|
// Disable IPv6 — no ip6tables rules exist, so services bound to 0.0.0.0
|
||||||
|
// may be reachable via IPv6. Disable it entirely at the kernel level.
|
||||||
|
rules = append(rules, "sysctl -w net.ipv6.conf.all.disable_ipv6=1")
|
||||||
|
rules = append(rules, "sysctl -w net.ipv6.conf.default.disable_ipv6=1")
|
||||||
|
|
||||||
// Enable firewall
|
// Enable firewall
|
||||||
rules = append(rules, "ufw --force enable")
|
rules = append(rules, "ufw --force enable")
|
||||||
@ -109,7 +114,7 @@ func (fp *FirewallProvisioner) GenerateRules() []string {
|
|||||||
// can be misclassified as "invalid" by conntrack due to reordering/jitter
|
// can be misclassified as "invalid" by conntrack due to reordering/jitter
|
||||||
// (especially between high-latency peers), causing silent packet drops.
|
// (especially between high-latency peers), causing silent packet drops.
|
||||||
// Inserting at position 1 in INPUT ensures this runs before UFW chains.
|
// Inserting at position 1 in INPUT ensures this runs before UFW chains.
|
||||||
rules = append(rules, "iptables -I INPUT 1 -i wg0 -s 10.0.0.0/8 -j ACCEPT")
|
rules = append(rules, "iptables -I INPUT 1 -i wg0 -s 10.0.0.0/24 -j ACCEPT")
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
}
|
}
|
||||||
@ -130,6 +135,22 @@ func (fp *FirewallProvisioner) Setup() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist IPv6 disable across reboots
|
||||||
|
if err := fp.persistIPv6Disable(); err != nil {
|
||||||
|
return fmt.Errorf("failed to persist IPv6 disable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistIPv6Disable writes a sysctl config to disable IPv6 on boot.
|
||||||
|
func (fp *FirewallProvisioner) persistIPv6Disable() error {
|
||||||
|
content := "# Orama Network: disable IPv6 (no ip6tables rules configured)\nnet.ipv6.conf.all.disable_ipv6 = 1\nnet.ipv6.conf.default.disable_ipv6 = 1\n"
|
||||||
|
cmd := exec.Command("tee", "/etc/sysctl.d/99-orama-disable-ipv6.conf")
|
||||||
|
cmd.Stdin = strings.NewReader(content)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to write sysctl config: %w\n%s", err, string(output))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,11 @@ func TestFirewallProvisioner_GenerateRules_StandardNode(t *testing.T) {
|
|||||||
assertContainsRule(t, rules, "ufw allow 51820/udp")
|
assertContainsRule(t, rules, "ufw allow 51820/udp")
|
||||||
assertContainsRule(t, rules, "ufw allow 80/tcp")
|
assertContainsRule(t, rules, "ufw allow 80/tcp")
|
||||||
assertContainsRule(t, rules, "ufw allow 443/tcp")
|
assertContainsRule(t, rules, "ufw allow 443/tcp")
|
||||||
assertContainsRule(t, rules, "ufw allow from 10.0.0.0/8")
|
assertContainsRule(t, rules, "ufw allow from 10.0.0.0/24")
|
||||||
|
assertContainsRule(t, rules, "sysctl -w net.ipv6.conf.all.disable_ipv6=1")
|
||||||
|
assertContainsRule(t, rules, "sysctl -w net.ipv6.conf.default.disable_ipv6=1")
|
||||||
assertContainsRule(t, rules, "ufw --force enable")
|
assertContainsRule(t, rules, "ufw --force enable")
|
||||||
assertContainsRule(t, rules, "iptables -I INPUT 1 -i wg0 -s 10.0.0.0/8 -j ACCEPT")
|
assertContainsRule(t, rules, "iptables -I INPUT 1 -i wg0 -s 10.0.0.0/24 -j ACCEPT")
|
||||||
|
|
||||||
// Should NOT contain DNS or Anyone relay
|
// Should NOT contain DNS or Anyone relay
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
@ -76,7 +78,7 @@ func TestFirewallProvisioner_GenerateRules_WireGuardSubnetAllowed(t *testing.T)
|
|||||||
|
|
||||||
rules := fp.GenerateRules()
|
rules := fp.GenerateRules()
|
||||||
|
|
||||||
assertContainsRule(t, rules, "ufw allow from 10.0.0.0/8")
|
assertContainsRule(t, rules, "ufw allow from 10.0.0.0/24")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirewallProvisioner_GenerateRules_FullConfig(t *testing.T) {
|
func TestFirewallProvisioner_GenerateRules_FullConfig(t *testing.T) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/constants"
|
"github.com/DeBrosOfficial/network/pkg/constants"
|
||||||
@ -323,8 +324,18 @@ rqlite:rqlite
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateCorefile creates the CoreDNS configuration (RQLite only)
|
// generateCorefile creates the CoreDNS configuration (RQLite only).
|
||||||
|
// If RQLite credentials exist on disk, they are included in the config.
|
||||||
func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN string) string {
|
func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN string) string {
|
||||||
|
// Read RQLite credentials from secrets if available
|
||||||
|
authBlock := ""
|
||||||
|
if data, err := os.ReadFile("/opt/orama/.orama/secrets/rqlite-password"); err == nil {
|
||||||
|
password := strings.TrimSpace(string(data))
|
||||||
|
if password != "" {
|
||||||
|
authBlock = fmt.Sprintf(" username orama\n password %s\n", password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(`# CoreDNS configuration for %s
|
return fmt.Sprintf(`# CoreDNS configuration for %s
|
||||||
# Uses RQLite for ALL DNS records (static + dynamic)
|
# Uses RQLite for ALL DNS records (static + dynamic)
|
||||||
# Static records (SOA, NS, A) are seeded into RQLite during installation
|
# Static records (SOA, NS, A) are seeded into RQLite during installation
|
||||||
@ -336,7 +347,7 @@ func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN string) string {
|
|||||||
refresh 5s
|
refresh 5s
|
||||||
ttl 30
|
ttl 30
|
||||||
cache_size 10000
|
cache_size 10000
|
||||||
}
|
%s }
|
||||||
|
|
||||||
# Enable logging and error reporting
|
# Enable logging and error reporting
|
||||||
log
|
log
|
||||||
@ -351,7 +362,7 @@ func (ci *CoreDNSInstaller) generateCorefile(domain, rqliteDSN string) string {
|
|||||||
cache 300
|
cache 300
|
||||||
errors
|
errors
|
||||||
}
|
}
|
||||||
`, domain, domain, rqliteDSN)
|
`, domain, domain, rqliteDSN, authBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedStaticRecords inserts static zone records into RQLite (non-destructive)
|
// seedStaticRecords inserts static zone records into RQLite (non-destructive)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package production
|
package production
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@ -256,6 +257,13 @@ func (ps *ProductionSetup) Phase2ProvisionEnvironment() error {
|
|||||||
}
|
}
|
||||||
ps.logf(" ✓ Directory structure created")
|
ps.logf(" ✓ Directory structure created")
|
||||||
|
|
||||||
|
// Create dedicated orama user for running services (non-root)
|
||||||
|
if err := ps.fsProvisioner.EnsureOramaUser(); err != nil {
|
||||||
|
ps.logf(" ⚠️ Could not create orama user: %v (services will run as root)", err)
|
||||||
|
} else {
|
||||||
|
ps.logf(" ✓ orama user ensured")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -477,6 +485,11 @@ func (ps *ProductionSetup) Phase2cInitializeServices(peerAddresses []string, vps
|
|||||||
return fmt.Errorf("failed to initialize IPFS Cluster: %w", err)
|
return fmt.Errorf("failed to initialize IPFS Cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After init, save own IPFS Cluster peer ID to trusted peers file
|
||||||
|
if err := ps.saveOwnClusterPeerID(clusterPath); err != nil {
|
||||||
|
ps.logf(" ⚠️ Could not save IPFS Cluster peer ID to trusted peers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize RQLite data directory
|
// Initialize RQLite data directory
|
||||||
rqliteDataDir := filepath.Join(dataDir, "rqlite")
|
rqliteDataDir := filepath.Join(dataDir, "rqlite")
|
||||||
if err := ps.binaryInstaller.InitializeRQLiteDataDir(rqliteDataDir); err != nil {
|
if err := ps.binaryInstaller.InitializeRQLiteDataDir(rqliteDataDir); err != nil {
|
||||||
@ -487,6 +500,50 @@ func (ps *ProductionSetup) Phase2cInitializeServices(peerAddresses []string, vps
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveOwnClusterPeerID reads this node's IPFS Cluster peer ID from identity.json
|
||||||
|
// and appends it to the trusted-peers file so EnsureConfig() can use it.
|
||||||
|
func (ps *ProductionSetup) saveOwnClusterPeerID(clusterPath string) error {
|
||||||
|
identityPath := filepath.Join(clusterPath, "identity.json")
|
||||||
|
data, err := os.ReadFile(identityPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read identity.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &identity); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse identity.json: %w", err)
|
||||||
|
}
|
||||||
|
if identity.ID == "" {
|
||||||
|
return fmt.Errorf("peer ID not found in identity.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing trusted peers
|
||||||
|
trustedPeersPath := filepath.Join(ps.oramaDir, "secrets", "ipfs-cluster-trusted-peers")
|
||||||
|
var existing []string
|
||||||
|
if fileData, err := os.ReadFile(trustedPeersPath); err == nil {
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(fileData)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
if line == identity.ID {
|
||||||
|
return nil // already present
|
||||||
|
}
|
||||||
|
existing = append(existing, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = append(existing, identity.ID)
|
||||||
|
content := strings.Join(existing, "\n") + "\n"
|
||||||
|
if err := os.WriteFile(trustedPeersPath, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write trusted peers file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.logf(" ✓ IPFS Cluster peer ID saved to trusted peers: %s", identity.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Phase3GenerateSecrets generates shared secrets and keys
|
// Phase3GenerateSecrets generates shared secrets and keys
|
||||||
func (ps *ProductionSetup) Phase3GenerateSecrets() error {
|
func (ps *ProductionSetup) Phase3GenerateSecrets() error {
|
||||||
ps.logf("Phase 3: Generating secrets...")
|
ps.logf("Phase 3: Generating secrets...")
|
||||||
@ -503,6 +560,24 @@ func (ps *ProductionSetup) Phase3GenerateSecrets() error {
|
|||||||
}
|
}
|
||||||
ps.logf(" ✓ IPFS swarm key ensured")
|
ps.logf(" ✓ IPFS swarm key ensured")
|
||||||
|
|
||||||
|
// RQLite auth credentials
|
||||||
|
if _, _, err := ps.secretGenerator.EnsureRQLiteAuth(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure RQLite auth: %w", err)
|
||||||
|
}
|
||||||
|
ps.logf(" ✓ RQLite auth credentials ensured")
|
||||||
|
|
||||||
|
// Olric gossip encryption key
|
||||||
|
if _, err := ps.secretGenerator.EnsureOlricEncryptionKey(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure Olric encryption key: %w", err)
|
||||||
|
}
|
||||||
|
ps.logf(" ✓ Olric encryption key ensured")
|
||||||
|
|
||||||
|
// API key HMAC secret
|
||||||
|
if _, err := ps.secretGenerator.EnsureAPIKeyHMACSecret(); err != nil {
|
||||||
|
return fmt.Errorf("failed to ensure API key HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
ps.logf(" ✓ API key HMAC secret ensured")
|
||||||
|
|
||||||
// Node identity (unified architecture)
|
// Node identity (unified architecture)
|
||||||
peerID, err := ps.secretGenerator.EnsureNodeIdentity()
|
peerID, err := ps.secretGenerator.EnsureNodeIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const (
|
|||||||
|
|
||||||
// Pre-built binary archive paths (created by `orama build`)
|
// Pre-built binary archive paths (created by `orama build`)
|
||||||
OramaManifest = "/opt/orama/manifest.json"
|
OramaManifest = "/opt/orama/manifest.json"
|
||||||
|
OramaManifestSig = "/opt/orama/manifest.sig"
|
||||||
OramaArchiveBin = "/opt/orama/bin" // Pre-built binaries
|
OramaArchiveBin = "/opt/orama/bin" // Pre-built binaries
|
||||||
OramaSystemdDir = "/opt/orama/systemd" // Namespace service templates
|
OramaSystemdDir = "/opt/orama/systemd" // Namespace service templates
|
||||||
OramaPackagesDir = "/opt/orama/packages" // .deb packages (e.g., anon.deb)
|
OramaPackagesDir = "/opt/orama/packages" // .deb packages (e.g., anon.deb)
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
package production
|
package production
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
ethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PreBuiltManifest describes the contents of a pre-built binary archive.
|
// PreBuiltManifest describes the contents of a pre-built binary archive.
|
||||||
@ -40,6 +45,74 @@ func LoadPreBuiltManifest() (*PreBuiltManifest, error) {
|
|||||||
return &manifest, nil
|
return &manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OramaSignerAddress is the Ethereum address authorized to sign build archives.
|
||||||
|
// Archives signed by any other address are rejected during install.
|
||||||
|
// This is the DeBros deploy wallet — update if the signing key rotates.
|
||||||
|
const OramaSignerAddress = "0x0000000000000000000000000000000000000000" // TODO: set real address
|
||||||
|
|
||||||
|
// VerifyArchiveSignature verifies that the pre-built archive was signed by the
|
||||||
|
// authorized Orama signer. Returns nil if the signature is valid, or if no
|
||||||
|
// signature file exists (unsigned archives are allowed but logged as a warning).
|
||||||
|
func VerifyArchiveSignature(manifest *PreBuiltManifest) error {
|
||||||
|
sigData, err := os.ReadFile(OramaManifestSig)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil // unsigned archive — caller decides whether to proceed
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read manifest.sig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the same hash used during signing: SHA256 of compact JSON
|
||||||
|
manifestJSON, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal manifest: %w", err)
|
||||||
|
}
|
||||||
|
manifestHash := sha256.Sum256(manifestJSON)
|
||||||
|
hashHex := hex.EncodeToString(manifestHash[:])
|
||||||
|
|
||||||
|
// EVM personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + message)
|
||||||
|
msg := []byte(hashHex)
|
||||||
|
prefix := []byte("\x19Ethereum Signed Message:\n" + fmt.Sprintf("%d", len(msg)))
|
||||||
|
ethHash := ethcrypto.Keccak256(prefix, msg)
|
||||||
|
|
||||||
|
// Decode signature
|
||||||
|
sigHex := strings.TrimSpace(string(sigData))
|
||||||
|
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
|
||||||
|
sigHex = sigHex[2:]
|
||||||
|
}
|
||||||
|
sig, err := hex.DecodeString(sigHex)
|
||||||
|
if err != nil || len(sig) != 65 {
|
||||||
|
return fmt.Errorf("invalid signature format in manifest.sig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize recovery ID
|
||||||
|
if sig[64] >= 27 {
|
||||||
|
sig[64] -= 27
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover public key from signature
|
||||||
|
pub, err := ethcrypto.SigToPub(ethHash, sig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("signature recovery failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recovered := ethcrypto.PubkeyToAddress(*pub).Hex()
|
||||||
|
expected := strings.ToLower(OramaSignerAddress)
|
||||||
|
got := strings.ToLower(recovered)
|
||||||
|
|
||||||
|
if got != expected {
|
||||||
|
return fmt.Errorf("archive signed by %s, expected %s — refusing to install", recovered, OramaSignerAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsArchiveSigned returns true if a manifest.sig file exists alongside the manifest.
|
||||||
|
func IsArchiveSigned() bool {
|
||||||
|
_, err := os.Stat(OramaManifestSig)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// installFromPreBuilt installs all binaries from a pre-built archive.
|
// installFromPreBuilt installs all binaries from a pre-built archive.
|
||||||
// The archive must already be extracted at /opt/orama/ with:
|
// The archive must already be extracted at /opt/orama/ with:
|
||||||
// - /opt/orama/bin/ — all pre-compiled binaries
|
// - /opt/orama/bin/ — all pre-compiled binaries
|
||||||
@ -49,6 +122,16 @@ func LoadPreBuiltManifest() (*PreBuiltManifest, error) {
|
|||||||
func (ps *ProductionSetup) installFromPreBuilt(manifest *PreBuiltManifest) error {
|
func (ps *ProductionSetup) installFromPreBuilt(manifest *PreBuiltManifest) error {
|
||||||
ps.logf(" Using pre-built binary archive v%s (%s) linux/%s", manifest.Version, manifest.Commit, manifest.Arch)
|
ps.logf(" Using pre-built binary archive v%s (%s) linux/%s", manifest.Version, manifest.Commit, manifest.Arch)
|
||||||
|
|
||||||
|
// Verify archive signature if present
|
||||||
|
if IsArchiveSigned() {
|
||||||
|
if err := VerifyArchiveSignature(manifest); err != nil {
|
||||||
|
return fmt.Errorf("archive signature verification failed: %w", err)
|
||||||
|
}
|
||||||
|
ps.logf(" ✓ Archive signature verified")
|
||||||
|
} else {
|
||||||
|
ps.logf(" ⚠️ Archive is unsigned — consider using 'orama build --sign'")
|
||||||
|
}
|
||||||
|
|
||||||
// Install minimal system dependencies (no build tools needed)
|
// Install minimal system dependencies (no build tools needed)
|
||||||
if err := ps.installMinimalSystemDeps(); err != nil {
|
if err := ps.installMinimalSystemDeps(); err != nil {
|
||||||
ps.logf(" ⚠️ System dependencies warning: %v", err)
|
ps.logf(" ⚠️ System dependencies warning: %v", err)
|
||||||
|
|||||||
@ -83,6 +83,38 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureOramaUser creates the 'orama' system user and group for running services.
|
||||||
|
// Sets ownership of the orama data directory to the new user.
|
||||||
|
func (fp *FilesystemProvisioner) EnsureOramaUser() error {
|
||||||
|
// Check if user already exists
|
||||||
|
if err := exec.Command("id", "orama").Run(); err == nil {
|
||||||
|
return nil // user already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create system user with no login shell and home at /opt/orama
|
||||||
|
cmd := exec.Command("useradd", "--system", "--no-create-home",
|
||||||
|
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ownership of orama directories
|
||||||
|
chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir)
|
||||||
|
if output, err := chown.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also chown the bin directory
|
||||||
|
binDir := filepath.Join(fp.oramaHome, "bin")
|
||||||
|
if _, err := os.Stat(binDir); err == nil {
|
||||||
|
chown = exec.Command("chown", "-R", "orama:orama", binDir)
|
||||||
|
if output, err := chown.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// StateDetector checks for existing production state
|
// StateDetector checks for existing production state
|
||||||
type StateDetector struct {
|
type StateDetector struct {
|
||||||
|
|||||||
@ -8,6 +8,17 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// oramaServiceHardening contains common systemd security directives for orama services.
|
||||||
|
const oramaServiceHardening = `User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes`
|
||||||
|
|
||||||
// SystemdServiceGenerator generates systemd unit files
|
// SystemdServiceGenerator generates systemd unit files
|
||||||
type SystemdServiceGenerator struct {
|
type SystemdServiceGenerator struct {
|
||||||
oramaHome string
|
oramaHome string
|
||||||
@ -34,6 +45,8 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[6]s
|
||||||
|
ReadWritePaths=%[3]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
Environment=IPFS_PATH=%[2]s
|
Environment=IPFS_PATH=%[2]s
|
||||||
ExecStartPre=/bin/bash -c 'if [ -f %[3]s/secrets/swarm.key ] && [ ! -f %[2]s/swarm.key ]; then cp %[3]s/secrets/swarm.key %[2]s/swarm.key && chmod 600 %[2]s/swarm.key; fi'
|
ExecStartPre=/bin/bash -c 'if [ -f %[3]s/secrets/swarm.key ] && [ ! -f %[2]s/swarm.key ]; then cp %[3]s/secrets/swarm.key %[2]s/swarm.key && chmod 600 %[2]s/swarm.key; fi'
|
||||||
@ -52,7 +65,7 @@ MemoryMax=4G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, ipfsRepoPath, ssg.oramaDir, logFile, ipfsBinary)
|
`, ssg.oramaHome, ipfsRepoPath, ssg.oramaDir, logFile, ipfsBinary, oramaServiceHardening)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateIPFSClusterService generates the IPFS Cluster systemd unit
|
// GenerateIPFSClusterService generates the IPFS Cluster systemd unit
|
||||||
@ -75,6 +88,8 @@ Requires=orama-ipfs.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[6]s
|
||||||
|
ReadWritePaths=%[7]s
|
||||||
WorkingDirectory=%[1]s
|
WorkingDirectory=%[1]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
Environment=IPFS_CLUSTER_PATH=%[2]s
|
Environment=IPFS_CLUSTER_PATH=%[2]s
|
||||||
@ -96,7 +111,7 @@ MemoryMax=2G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, clusterPath, logFile, clusterBinary, clusterSecret)
|
`, ssg.oramaHome, clusterPath, logFile, clusterBinary, clusterSecret, oramaServiceHardening, ssg.oramaDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateRQLiteService generates the RQLite systemd unit
|
// GenerateRQLiteService generates the RQLite systemd unit
|
||||||
@ -128,6 +143,8 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[6]s
|
||||||
|
ReadWritePaths=%[7]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
ExecStart=%[5]s %[2]s
|
ExecStart=%[5]s %[2]s
|
||||||
Restart=always
|
Restart=always
|
||||||
@ -143,7 +160,7 @@ KillMode=mixed
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, args, logFile, dataDir, rqliteBinary)
|
`, ssg.oramaHome, args, logFile, dataDir, rqliteBinary, oramaServiceHardening, ssg.oramaDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOlricService generates the Olric systemd unit
|
// GenerateOlricService generates the Olric systemd unit
|
||||||
@ -158,6 +175,8 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[6]s
|
||||||
|
ReadWritePaths=%[4]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
Environment=OLRIC_SERVER_CONFIG=%[2]s
|
Environment=OLRIC_SERVER_CONFIG=%[2]s
|
||||||
ExecStart=%[5]s
|
ExecStart=%[5]s
|
||||||
@ -175,7 +194,7 @@ MemoryMax=4G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, olricConfigPath, logFile, ssg.oramaDir, olricBinary)
|
`, ssg.oramaHome, olricConfigPath, logFile, ssg.oramaDir, olricBinary, oramaServiceHardening)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateNodeService generates the Orama Node systemd unit
|
// GenerateNodeService generates the Orama Node systemd unit
|
||||||
@ -193,6 +212,8 @@ Requires=wg-quick@wg0.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[5]s
|
||||||
|
ReadWritePaths=%[2]s
|
||||||
WorkingDirectory=%[1]s
|
WorkingDirectory=%[1]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
ExecStart=%[1]s/bin/orama-node --config %[2]s/configs/%[3]s
|
ExecStart=%[1]s/bin/orama-node --config %[2]s/configs/%[3]s
|
||||||
@ -211,7 +232,7 @@ OOMScoreAdjust=-500
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile)
|
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaServiceHardening)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateVaultService generates the Orama Vault Guardian systemd unit.
|
// GenerateVaultService generates the Orama Vault Guardian systemd unit.
|
||||||
@ -230,6 +251,16 @@ PartOf=orama-node.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths=%[2]s
|
||||||
ExecStart=%[1]s/bin/vault-guardian --config %[2]s/vault.yaml
|
ExecStart=%[1]s/bin/vault-guardian --config %[2]s/vault.yaml
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@ -238,9 +269,6 @@ StandardError=append:%[3]s
|
|||||||
SyslogIdentifier=orama-vault
|
SyslogIdentifier=orama-vault
|
||||||
|
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=%[2]s
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
LimitMEMLOCK=67108864
|
LimitMEMLOCK=67108864
|
||||||
MemoryMax=512M
|
MemoryMax=512M
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
@ -261,6 +289,8 @@ Wants=orama-node.service orama-olric.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[4]s
|
||||||
|
ReadWritePaths=%[2]s
|
||||||
WorkingDirectory=%[1]s
|
WorkingDirectory=%[1]s
|
||||||
Environment=HOME=%[1]s
|
Environment=HOME=%[1]s
|
||||||
ExecStart=%[1]s/bin/gateway --config %[2]s/data/gateway.yaml
|
ExecStart=%[1]s/bin/gateway --config %[2]s/data/gateway.yaml
|
||||||
@ -278,7 +308,7 @@ MemoryMax=4G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, ssg.oramaHome, ssg.oramaDir, logFile)
|
`, ssg.oramaHome, ssg.oramaDir, logFile, oramaServiceHardening)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAnyoneClientService generates the Anyone Client SOCKS5 proxy systemd unit.
|
// GenerateAnyoneClientService generates the Anyone Client SOCKS5 proxy systemd unit.
|
||||||
@ -353,7 +383,7 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
// GenerateCoreDNSService generates the CoreDNS systemd unit
|
// GenerateCoreDNSService generates the CoreDNS systemd unit
|
||||||
func (ssg *SystemdServiceGenerator) GenerateCoreDNSService() string {
|
func (ssg *SystemdServiceGenerator) GenerateCoreDNSService() string {
|
||||||
return `[Unit]
|
return fmt.Sprintf(`[Unit]
|
||||||
Description=CoreDNS DNS Server with RQLite backend
|
Description=CoreDNS DNS Server with RQLite backend
|
||||||
Documentation=https://coredns.io
|
Documentation=https://coredns.io
|
||||||
After=network-online.target orama-node.service
|
After=network-online.target orama-node.service
|
||||||
@ -361,11 +391,16 @@ Wants=network-online.target orama-node.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[1]s
|
||||||
|
ReadWritePaths=%[2]s
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
ExecStart=/usr/local/bin/coredns -conf /etc/coredns/Corefile
|
ExecStart=/usr/local/bin/coredns -conf /etc/coredns/Corefile
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
SyslogIdentifier=coredns
|
SyslogIdentifier=coredns
|
||||||
|
|
||||||
|
PrivateTmp=yes
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=65536
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
KillMode=mixed
|
KillMode=mixed
|
||||||
@ -373,12 +408,12 @@ MemoryMax=1G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`
|
`, oramaServiceHardening, ssg.oramaDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCaddyService generates the Caddy systemd unit for SSL/TLS
|
// GenerateCaddyService generates the Caddy systemd unit for SSL/TLS
|
||||||
func (ssg *SystemdServiceGenerator) GenerateCaddyService() string {
|
func (ssg *SystemdServiceGenerator) GenerateCaddyService() string {
|
||||||
return `[Unit]
|
return fmt.Sprintf(`[Unit]
|
||||||
Description=Caddy HTTP/2 Server
|
Description=Caddy HTTP/2 Server
|
||||||
Documentation=https://caddyserver.com/docs/
|
Documentation=https://caddyserver.com/docs/
|
||||||
After=network-online.target orama-node.service coredns.service
|
After=network-online.target orama-node.service coredns.service
|
||||||
@ -387,6 +422,10 @@ Wants=orama-node.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
%[1]s
|
||||||
|
ReadWritePaths=%[2]s /var/lib/caddy /etc/caddy
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
|
||||||
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile
|
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile
|
||||||
TimeoutStopSec=5s
|
TimeoutStopSec=5s
|
||||||
@ -401,7 +440,7 @@ MemoryMax=2G
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`
|
`, oramaServiceHardening, ssg.oramaDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemdController manages systemd service operations
|
// SystemdController manages systemd service operations
|
||||||
|
|||||||
@ -117,8 +117,8 @@ func (wp *WireGuardProvisioner) GenerateConfig() string {
|
|||||||
|
|
||||||
// Accept all WireGuard subnet traffic before UFW's conntrack "invalid" drop.
|
// Accept all WireGuard subnet traffic before UFW's conntrack "invalid" drop.
|
||||||
// Without this, packets reordered by the tunnel get silently dropped.
|
// Without this, packets reordered by the tunnel get silently dropped.
|
||||||
sb.WriteString("PostUp = iptables -I INPUT 1 -i wg0 -s 10.0.0.0/8 -j ACCEPT\n")
|
sb.WriteString("PostUp = iptables -I INPUT 1 -i wg0 -s 10.0.0.0/24 -j ACCEPT\n")
|
||||||
sb.WriteString("PostDown = iptables -D INPUT -i wg0 -s 10.0.0.0/8 -j ACCEPT\n")
|
sb.WriteString("PostDown = iptables -D INPUT -i wg0 -s 10.0.0.0/24 -j ACCEPT\n")
|
||||||
|
|
||||||
for _, peer := range wp.config.Peers {
|
for _, peer := range wp.config.Peers {
|
||||||
sb.WriteString("\n[Peer]\n")
|
sb.WriteString("\n[Peer]\n")
|
||||||
|
|||||||
@ -95,10 +95,10 @@ func TestWireGuardProvisioner_GenerateConfig_NoPeers(t *testing.T) {
|
|||||||
if !strings.Contains(config, "PrivateKey = dGVzdHByaXZhdGVrZXl0ZXN0cHJpdmF0ZWtleXM=") {
|
if !strings.Contains(config, "PrivateKey = dGVzdHByaXZhdGVrZXl0ZXN0cHJpdmF0ZWtleXM=") {
|
||||||
t.Error("config should contain PrivateKey")
|
t.Error("config should contain PrivateKey")
|
||||||
}
|
}
|
||||||
if !strings.Contains(config, "PostUp = iptables -I INPUT 1 -i wg0 -s 10.0.0.0/8 -j ACCEPT") {
|
if !strings.Contains(config, "PostUp = iptables -I INPUT 1 -i wg0 -s 10.0.0.0/24 -j ACCEPT") {
|
||||||
t.Error("config should contain PostUp iptables rule for WireGuard subnet")
|
t.Error("config should contain PostUp iptables rule for WireGuard subnet")
|
||||||
}
|
}
|
||||||
if !strings.Contains(config, "PostDown = iptables -D INPUT -i wg0 -s 10.0.0.0/8 -j ACCEPT") {
|
if !strings.Contains(config, "PostDown = iptables -D INPUT -i wg0 -s 10.0.0.0/24 -j ACCEPT") {
|
||||||
t.Error("config should contain PostDown iptables cleanup rule")
|
t.Error("config should contain PostDown iptables cleanup rule")
|
||||||
}
|
}
|
||||||
if strings.Contains(config, "[Peer]") {
|
if strings.Contains(config, "[Peer]") {
|
||||||
|
|||||||
@ -15,3 +15,6 @@ memberlist:
|
|||||||
- "{{.}}"
|
- "{{.}}"
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{- if .EncryptionKey}}
|
||||||
|
encryptionKey: "{{.EncryptionKey}}"
|
||||||
|
{{- end}}
|
||||||
|
|||||||
@ -65,6 +65,7 @@ type OlricConfigData struct {
|
|||||||
MemberlistEnvironment string // "local", "lan", or "wan"
|
MemberlistEnvironment string // "local", "lan", or "wan"
|
||||||
MemberlistAdvertiseAddr string // Advertise address (WG IP) so other nodes can reach us
|
MemberlistAdvertiseAddr string // Advertise address (WG IP) so other nodes can reach us
|
||||||
Peers []string // Seed peers for memberlist (host:port)
|
Peers []string // Seed peers for memberlist (host:port)
|
||||||
|
EncryptionKey string // Base64-encoded 32-byte key for memberlist gossip encryption (empty = no encryption)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemdIPFSData holds parameters for systemd IPFS service rendering
|
// SystemdIPFSData holds parameters for systemd IPFS service rendering
|
||||||
|
|||||||
@ -5,6 +5,16 @@ Wants=orama-node.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths={{.OramaDir}}
|
||||||
WorkingDirectory={{.HomeDir}}
|
WorkingDirectory={{.HomeDir}}
|
||||||
Environment=HOME={{.HomeDir}}
|
Environment=HOME={{.HomeDir}}
|
||||||
ExecStart={{.HomeDir}}/bin/gateway --config {{.OramaDir}}/data/gateway.yaml
|
ExecStart={{.HomeDir}}/bin/gateway --config {{.OramaDir}}/data/gateway.yaml
|
||||||
|
|||||||
@ -5,6 +5,16 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths={{.IPFSRepoPath}} {{.OramaDir}}
|
||||||
Environment=HOME={{.HomeDir}}
|
Environment=HOME={{.HomeDir}}
|
||||||
Environment=IPFS_PATH={{.IPFSRepoPath}}
|
Environment=IPFS_PATH={{.IPFSRepoPath}}
|
||||||
ExecStartPre=/bin/bash -c 'if [ -f {{.SecretsDir}}/swarm.key ] && [ ! -f {{.IPFSRepoPath}}/swarm.key ]; then cp {{.SecretsDir}}/swarm.key {{.IPFSRepoPath}}/swarm.key && chmod 600 {{.IPFSRepoPath}}/swarm.key; fi'
|
ExecStartPre=/bin/bash -c 'if [ -f {{.SecretsDir}}/swarm.key ] && [ ! -f {{.IPFSRepoPath}}/swarm.key ]; then cp {{.SecretsDir}}/swarm.key {{.IPFSRepoPath}}/swarm.key && chmod 600 {{.IPFSRepoPath}}/swarm.key; fi'
|
||||||
|
|||||||
@ -6,6 +6,16 @@ Requires=orama-ipfs-{{.NodeType}}.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths={{.ClusterPath}} {{.OramaDir}}
|
||||||
WorkingDirectory={{.HomeDir}}
|
WorkingDirectory={{.HomeDir}}
|
||||||
Environment=HOME={{.HomeDir}}
|
Environment=HOME={{.HomeDir}}
|
||||||
Environment=CLUSTER_PATH={{.ClusterPath}}
|
Environment=CLUSTER_PATH={{.ClusterPath}}
|
||||||
|
|||||||
@ -6,6 +6,16 @@ Requires=orama-ipfs-cluster-{{.NodeType}}.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths={{.OramaDir}}
|
||||||
WorkingDirectory={{.HomeDir}}
|
WorkingDirectory={{.HomeDir}}
|
||||||
Environment=HOME={{.HomeDir}}
|
Environment=HOME={{.HomeDir}}
|
||||||
ExecStart={{.HomeDir}}/bin/orama-node --config {{.OramaDir}}/configs/{{.ConfigFile}}
|
ExecStart={{.HomeDir}}/bin/orama-node --config {{.OramaDir}}/configs/{{.ConfigFile}}
|
||||||
|
|||||||
@ -5,6 +5,16 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=orama
|
||||||
|
Group=orama
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
ReadWritePaths={{.OramaDir}}
|
||||||
Environment=HOME={{.HomeDir}}
|
Environment=HOME={{.HomeDir}}
|
||||||
Environment=OLRIC_SERVER_CONFIG={{.ConfigPath}}
|
Environment=OLRIC_SERVER_CONFIG={{.ConfigPath}}
|
||||||
ExecStart=/usr/local/bin/olric-server
|
ExecStart=/usr/local/bin/olric-server
|
||||||
|
|||||||
24
pkg/gateway/auth/crypto.go
Normal file
24
pkg/gateway/auth/crypto.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sha256Hex returns the lowercase hex-encoded SHA-256 hash of the input string.
|
||||||
|
// Used to hash refresh tokens before storage — deterministic so we can hash on
|
||||||
|
// insert and hash on lookup without storing the raw token.
|
||||||
|
func sha256Hex(s string) string {
|
||||||
|
h := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// HmacSHA256Hex computes HMAC-SHA256 of data with the given secret key and
|
||||||
|
// returns the result as a lowercase hex string. Used for API key hashing —
|
||||||
|
// fast and deterministic, allowing direct DB lookup by hash.
|
||||||
|
func HmacSHA256Hex(data, secret string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(data))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
@ -32,6 +32,7 @@ type Service struct {
|
|||||||
edKeyID string
|
edKeyID string
|
||||||
preferEdDSA bool
|
preferEdDSA bool
|
||||||
defaultNS string
|
defaultNS string
|
||||||
|
apiKeyHMACSecret string // HMAC secret for hashing API keys before storage
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(logger *logging.ColoredLogger, orm client.NetworkClient, signingKeyPEM string, defaultNS string) (*Service, error) {
|
func NewService(logger *logging.ColoredLogger, orm client.NetworkClient, signingKeyPEM string, defaultNS string) (*Service, error) {
|
||||||
@ -61,6 +62,21 @@ func NewService(logger *logging.ColoredLogger, orm client.NetworkClient, signing
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAPIKeyHMACSecret configures the HMAC secret used to hash API keys before storage.
|
||||||
|
// When set, API keys are stored as HMAC-SHA256(key, secret) in the database.
|
||||||
|
func (s *Service) SetAPIKeyHMACSecret(secret string) {
|
||||||
|
s.apiKeyHMACSecret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashAPIKey returns the HMAC-SHA256 hash of an API key if the HMAC secret is set,
|
||||||
|
// or returns the raw key for backward compatibility during rolling upgrade.
|
||||||
|
func (s *Service) HashAPIKey(key string) string {
|
||||||
|
if s.apiKeyHMACSecret == "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return HmacSHA256Hex(key, s.apiKeyHMACSecret)
|
||||||
|
}
|
||||||
|
|
||||||
// SetEdDSAKey configures an Ed25519 signing key for EdDSA JWT support.
|
// SetEdDSAKey configures an Ed25519 signing key for EdDSA JWT support.
|
||||||
// When set, new tokens are signed with EdDSA; RS256 is still accepted for verification.
|
// When set, new tokens are signed with EdDSA; RS256 is still accepted for verification.
|
||||||
func (s *Service) SetEdDSAKey(privKey ed25519.PrivateKey) {
|
func (s *Service) SetEdDSAKey(privKey ed25519.PrivateKey) {
|
||||||
@ -207,9 +223,10 @@ func (s *Service) IssueTokens(ctx context.Context, wallet, namespace string) (st
|
|||||||
|
|
||||||
internalCtx := client.WithInternalAuth(ctx)
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
db := s.orm.Database()
|
db := s.orm.Database()
|
||||||
|
hashedRefresh := sha256Hex(refresh)
|
||||||
if _, err := db.Query(internalCtx,
|
if _, err := db.Query(internalCtx,
|
||||||
"INSERT INTO refresh_tokens(namespace_id, subject, token, audience, expires_at) VALUES (?, ?, ?, ?, datetime('now', '+30 days'))",
|
"INSERT INTO refresh_tokens(namespace_id, subject, token, audience, expires_at) VALUES (?, ?, ?, ?, datetime('now', '+30 days'))",
|
||||||
nsID, wallet, refresh, "gateway",
|
nsID, wallet, hashedRefresh, "gateway",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", "", 0, fmt.Errorf("failed to store refresh token: %w", err)
|
return "", "", 0, fmt.Errorf("failed to store refresh token: %w", err)
|
||||||
}
|
}
|
||||||
@ -227,8 +244,9 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken, namespace stri
|
|||||||
return "", "", 0, err
|
return "", "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hashedRefresh := sha256Hex(refreshToken)
|
||||||
q := "SELECT subject FROM refresh_tokens WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
|
q := "SELECT subject FROM refresh_tokens WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1"
|
||||||
res, err := db.Query(internalCtx, q, nsID, refreshToken)
|
res, err := db.Query(internalCtx, q, nsID, hashedRefresh)
|
||||||
if err != nil || res == nil || res.Count == 0 {
|
if err != nil || res == nil || res.Count == 0 {
|
||||||
return "", "", 0, fmt.Errorf("invalid or expired refresh token")
|
return "", "", 0, fmt.Errorf("invalid or expired refresh token")
|
||||||
}
|
}
|
||||||
@ -262,7 +280,8 @@ func (s *Service) RevokeToken(ctx context.Context, namespace, token string, all
|
|||||||
}
|
}
|
||||||
|
|
||||||
if token != "" {
|
if token != "" {
|
||||||
_, err := db.Query(internalCtx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL", nsID, token)
|
hashedToken := sha256Hex(token)
|
||||||
|
_, err := db.Query(internalCtx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE namespace_id = ? AND token = ? AND revoked_at IS NULL", nsID, hashedToken)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,19 +354,21 @@ func (s *Service) GetOrCreateAPIKey(ctx context.Context, wallet, namespace strin
|
|||||||
}
|
}
|
||||||
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + namespace
|
apiKey = "ak_" + base64.RawURLEncoding.EncodeToString(buf) + ":" + namespace
|
||||||
|
|
||||||
if _, err := db.Query(internalCtx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", apiKey, "", nsID); err != nil {
|
// Store the HMAC hash of the key (not the raw key) if HMAC secret is configured
|
||||||
|
hashedKey := s.HashAPIKey(apiKey)
|
||||||
|
if _, err := db.Query(internalCtx, "INSERT INTO api_keys(key, name, namespace_id) VALUES (?, ?, ?)", hashedKey, "", nsID); err != nil {
|
||||||
return "", fmt.Errorf("failed to store api key: %w", err)
|
return "", fmt.Errorf("failed to store api key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link wallet -> api_key
|
// Link wallet -> api_key
|
||||||
rid, err := db.Query(internalCtx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", apiKey)
|
rid, err := db.Query(internalCtx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", hashedKey)
|
||||||
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
|
if err == nil && rid != nil && rid.Count > 0 && len(rid.Rows) > 0 && len(rid.Rows[0]) > 0 {
|
||||||
apiKeyID := rid.Rows[0][0]
|
apiKeyID := rid.Rows[0][0]
|
||||||
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(wallet), apiKeyID)
|
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO wallet_api_keys(namespace_id, wallet, api_key_id) VALUES (?, ?, ?)", nsID, strings.ToLower(wallet), apiKeyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record ownerships
|
// Record ownerships — store the hash in ownership too
|
||||||
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, apiKey)
|
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'api_key', ?)", nsID, hashedKey)
|
||||||
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, wallet)
|
_, _ = db.Query(internalCtx, "INSERT OR IGNORE INTO namespace_ownership(namespace_id, owner_type, owner_id) VALUES (?, 'wallet', ?)", nsID, wallet)
|
||||||
|
|
||||||
return apiKey, nil
|
return apiKey, nil
|
||||||
|
|||||||
@ -39,9 +39,18 @@ type Config struct {
|
|||||||
IPFSReplicationFactor int // Replication factor for pins (default: 3)
|
IPFSReplicationFactor int // Replication factor for pins (default: 3)
|
||||||
IPFSEnableEncryption bool // Enable client-side encryption before upload (default: true, discovered from node configs)
|
IPFSEnableEncryption bool // Enable client-side encryption before upload (default: true, discovered from node configs)
|
||||||
|
|
||||||
|
// RQLite authentication (basic auth credentials embedded in DSN)
|
||||||
|
RQLiteUsername string // RQLite HTTP basic auth username (default: "orama")
|
||||||
|
RQLitePassword string // RQLite HTTP basic auth password
|
||||||
|
|
||||||
// WireGuard mesh configuration
|
// WireGuard mesh configuration
|
||||||
ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange
|
ClusterSecret string // Cluster secret for authenticating internal WireGuard peer exchange
|
||||||
|
|
||||||
|
// API key HMAC secret for hashing API keys before storage.
|
||||||
|
// When set, API keys are stored as HMAC-SHA256(key, secret) in the database.
|
||||||
|
// Loaded from ~/.orama/secrets/api-key-hmac-secret.
|
||||||
|
APIKeyHMACSecret string
|
||||||
|
|
||||||
// WebRTC configuration (set when namespace has WebRTC enabled)
|
// WebRTC configuration (set when namespace has WebRTC enabled)
|
||||||
WebRTCEnabled bool // Whether WebRTC endpoints are active on this gateway
|
WebRTCEnabled bool // Whether WebRTC endpoints are active on this gateway
|
||||||
SFUPort int // Local SFU signaling port to proxy WebSocket connections to
|
SFUPort int // Local SFU signaling port to proxy WebSocket connections to
|
||||||
|
|||||||
@ -86,6 +86,7 @@ func NewDependencies(logger *logging.ColoredLogger, cfg *Config) (*Dependencies,
|
|||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
dsn = "http://localhost:5001"
|
dsn = "http://localhost:5001"
|
||||||
}
|
}
|
||||||
|
dsn = injectRQLiteAuth(dsn, cfg.RQLiteUsername, cfg.RQLitePassword)
|
||||||
cliCfg.DatabaseEndpoints = []string{dsn}
|
cliCfg.DatabaseEndpoints = []string{dsn}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,6 +137,9 @@ func initializeRQLite(logger *logging.ColoredLogger, cfg *Config, deps *Dependen
|
|||||||
dsn = "http://localhost:5001"
|
dsn = "http://localhost:5001"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject basic auth credentials into DSN if available
|
||||||
|
dsn = injectRQLiteAuth(dsn, cfg.RQLiteUsername, cfg.RQLitePassword)
|
||||||
|
|
||||||
if strings.Contains(dsn, "?") {
|
if strings.Contains(dsn, "?") {
|
||||||
dsn += "&disableClusterDiscovery=true&level=none"
|
dsn += "&disableClusterDiscovery=true&level=none"
|
||||||
} else {
|
} else {
|
||||||
@ -483,6 +487,12 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
|
|||||||
logger.ComponentInfo(logging.ComponentGeneral, "EdDSA signing key loaded; new JWTs will use EdDSA")
|
logger.ComponentInfo(logging.ComponentGeneral, "EdDSA signing key loaded; new JWTs will use EdDSA")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure API key HMAC secret if available
|
||||||
|
if cfg.APIKeyHMACSecret != "" {
|
||||||
|
authService.SetAPIKeyHMACSecret(cfg.APIKeyHMACSecret)
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "API key HMAC secret loaded; new API keys will be hashed")
|
||||||
|
}
|
||||||
|
|
||||||
deps.AuthService = authService
|
deps.AuthService = authService
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentGeneral, "Serverless function engine ready",
|
logger.ComponentInfo(logging.ComponentGeneral, "Serverless function engine ready",
|
||||||
@ -660,3 +670,19 @@ func discoverIPFSFromNodeConfigs(logger *zap.Logger) ipfsDiscoveryResult {
|
|||||||
|
|
||||||
return ipfsDiscoveryResult{}
|
return ipfsDiscoveryResult{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// injectRQLiteAuth injects HTTP basic auth credentials into a RQLite DSN URL.
|
||||||
|
// If username or password is empty, the DSN is returned unchanged.
|
||||||
|
// Input: "http://localhost:5001" → Output: "http://orama:secret@localhost:5001"
|
||||||
|
func injectRQLiteAuth(dsn, username, password string) string {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
// Insert user:pass@ after the scheme (http:// or https://)
|
||||||
|
for _, scheme := range []string{"https://", "http://"} {
|
||||||
|
if strings.HasPrefix(dsn, scheme) {
|
||||||
|
return scheme + username + ":" + password + "@" + dsn[len(scheme):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|||||||
@ -313,7 +313,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
|||||||
|
|
||||||
// Create client config for global namespace
|
// Create client config for global namespace
|
||||||
authCfg := client.DefaultClientConfig("default") // Use "default" namespace for global
|
authCfg := client.DefaultClientConfig("default") // Use "default" namespace for global
|
||||||
authCfg.DatabaseEndpoints = []string{cfg.GlobalRQLiteDSN}
|
authCfg.DatabaseEndpoints = []string{injectRQLiteAuth(cfg.GlobalRQLiteDSN, cfg.RQLiteUsername, cfg.RQLitePassword)}
|
||||||
if len(cfg.BootstrapPeers) > 0 {
|
if len(cfg.BootstrapPeers) > 0 {
|
||||||
authCfg.BootstrapPeers = cfg.BootstrapPeers
|
authCfg.BootstrapPeers = cfg.BootstrapPeers
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,11 +34,15 @@ type JoinResponse struct {
|
|||||||
// Secrets
|
// Secrets
|
||||||
ClusterSecret string `json:"cluster_secret"`
|
ClusterSecret string `json:"cluster_secret"`
|
||||||
SwarmKey string `json:"swarm_key"`
|
SwarmKey string `json:"swarm_key"`
|
||||||
|
APIKeyHMACSecret string `json:"api_key_hmac_secret,omitempty"`
|
||||||
|
RQLitePassword string `json:"rqlite_password,omitempty"`
|
||||||
|
OlricEncryptionKey string `json:"olric_encryption_key,omitempty"`
|
||||||
|
|
||||||
// Cluster join info (all using WG IPs)
|
// Cluster join info (all using WG IPs)
|
||||||
RQLiteJoinAddress string `json:"rqlite_join_address"`
|
RQLiteJoinAddress string `json:"rqlite_join_address"`
|
||||||
IPFSPeer PeerInfo `json:"ipfs_peer"`
|
IPFSPeer PeerInfo `json:"ipfs_peer"`
|
||||||
IPFSClusterPeer PeerInfo `json:"ipfs_cluster_peer"`
|
IPFSClusterPeer PeerInfo `json:"ipfs_cluster_peer"`
|
||||||
|
IPFSClusterPeerIDs []string `json:"ipfs_cluster_peer_ids,omitempty"`
|
||||||
BootstrapPeers []string `json:"bootstrap_peers"`
|
BootstrapPeers []string `json:"bootstrap_peers"`
|
||||||
|
|
||||||
// Olric seed peers (WG IP:port for memberlist)
|
// Olric seed peers (WG IP:port for memberlist)
|
||||||
@ -155,6 +159,24 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read API key HMAC secret (optional — may not exist on older clusters)
|
||||||
|
apiKeyHMACSecret := ""
|
||||||
|
if data, err := os.ReadFile(h.oramaDir + "/secrets/api-key-hmac-secret"); err == nil {
|
||||||
|
apiKeyHMACSecret = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read RQLite password (optional — may not exist on older clusters)
|
||||||
|
rqlitePassword := ""
|
||||||
|
if data, err := os.ReadFile(h.oramaDir + "/secrets/rqlite-password"); err == nil {
|
||||||
|
rqlitePassword = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Olric encryption key (optional — may not exist on older clusters)
|
||||||
|
olricEncryptionKey := ""
|
||||||
|
if data, err := os.ReadFile(h.oramaDir + "/secrets/olric-encryption-key"); err == nil {
|
||||||
|
olricEncryptionKey = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Get all WG peers
|
// 7. Get all WG peers
|
||||||
wgPeers, err := h.getWGPeers(ctx, req.WGPublicKey)
|
wgPeers, err := h.getWGPeers(ctx, req.WGPublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -181,6 +203,9 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 11. Read base domain from config
|
// 11. Read base domain from config
|
||||||
baseDomain := h.readBaseDomain()
|
baseDomain := h.readBaseDomain()
|
||||||
|
|
||||||
|
// 12. Read IPFS Cluster trusted peer IDs
|
||||||
|
ipfsClusterPeerIDs := h.readIPFSClusterTrustedPeers()
|
||||||
|
|
||||||
// Build Olric seed peers from all existing WG peer IPs (memberlist port 3322)
|
// Build Olric seed peers from all existing WG peer IPs (memberlist port 3322)
|
||||||
var olricPeers []string
|
var olricPeers []string
|
||||||
for _, p := range wgPeers {
|
for _, p := range wgPeers {
|
||||||
@ -195,9 +220,13 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
|
|||||||
WGPeers: wgPeers,
|
WGPeers: wgPeers,
|
||||||
ClusterSecret: strings.TrimSpace(string(clusterSecret)),
|
ClusterSecret: strings.TrimSpace(string(clusterSecret)),
|
||||||
SwarmKey: strings.TrimSpace(string(swarmKey)),
|
SwarmKey: strings.TrimSpace(string(swarmKey)),
|
||||||
|
APIKeyHMACSecret: apiKeyHMACSecret,
|
||||||
|
RQLitePassword: rqlitePassword,
|
||||||
|
OlricEncryptionKey: olricEncryptionKey,
|
||||||
RQLiteJoinAddress: fmt.Sprintf("%s:7001", myWGIP),
|
RQLiteJoinAddress: fmt.Sprintf("%s:7001", myWGIP),
|
||||||
IPFSPeer: ipfsPeer,
|
IPFSPeer: ipfsPeer,
|
||||||
IPFSClusterPeer: ipfsClusterPeer,
|
IPFSClusterPeer: ipfsClusterPeer,
|
||||||
|
IPFSClusterPeerIDs: ipfsClusterPeerIDs,
|
||||||
BootstrapPeers: bootstrapPeers,
|
BootstrapPeers: bootstrapPeers,
|
||||||
OlricPeers: olricPeers,
|
OlricPeers: olricPeers,
|
||||||
BaseDomain: baseDomain,
|
BaseDomain: baseDomain,
|
||||||
@ -454,6 +483,22 @@ func (h *Handler) buildBootstrapPeers(myWGIP, ipfsPeerID string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readIPFSClusterTrustedPeers reads IPFS Cluster trusted peer IDs from the secrets file
|
||||||
|
func (h *Handler) readIPFSClusterTrustedPeers() []string {
|
||||||
|
data, err := os.ReadFile(h.oramaDir + "/secrets/ipfs-cluster-trusted-peers")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var peers []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
peers = append(peers, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
// readBaseDomain reads the base domain from node config
|
// readBaseDomain reads the base domain from node config
|
||||||
func (h *Handler) readBaseDomain() string {
|
func (h *Handler) readBaseDomain() string {
|
||||||
data, err := os.ReadFile(h.oramaDir + "/configs/node.yaml")
|
data, err := os.ReadFile(h.oramaDir + "/configs/node.yaml")
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||||
@ -14,8 +16,29 @@ import (
|
|||||||
var wsUpgrader = websocket.Upgrader{
|
var wsUpgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
// For early development we accept any origin; tighten later.
|
CheckOrigin: checkWSOrigin,
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
}
|
||||||
|
|
||||||
|
// checkWSOrigin validates WebSocket origins against the request's Host header.
|
||||||
|
// Non-browser clients (no Origin) are allowed. Browser clients must match the host.
|
||||||
|
func checkWSOrigin(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
originHost := parsed.Hostname()
|
||||||
|
return originHost == host || strings.HasSuffix(originHost, "."+host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsClient wraps a WebSocket connection with message handling
|
// wsClient wraps a WebSocket connection with message handling
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/serverless"
|
"github.com/DeBrosOfficial/network/pkg/serverless"
|
||||||
@ -12,6 +14,29 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// checkWSOrigin validates WebSocket origins against the request's Host header.
|
||||||
|
// Non-browser clients (no Origin) are allowed. Browser clients must match the host.
|
||||||
|
func checkWSOrigin(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if host == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Strip port from host if present
|
||||||
|
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||||
|
host = host[:idx]
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
originHost := parsed.Hostname()
|
||||||
|
return originHost == host || strings.HasSuffix(originHost, "."+host)
|
||||||
|
}
|
||||||
|
|
||||||
// HandleWebSocket handles WebSocket connections for function streaming.
|
// HandleWebSocket handles WebSocket connections for function streaming.
|
||||||
// It upgrades HTTP connections to WebSocket and manages bi-directional communication
|
// It upgrades HTTP connections to WebSocket and manages bi-directional communication
|
||||||
// for real-time function invocation and streaming responses.
|
// for real-time function invocation and streaming responses.
|
||||||
@ -28,7 +53,7 @@ func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Requ
|
|||||||
|
|
||||||
// Upgrade to WebSocket
|
// Upgrade to WebSocket
|
||||||
upgrader := websocket.Upgrader{
|
upgrader := websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: checkWSOrigin,
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/auth"
|
||||||
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@ -129,6 +130,11 @@ func (h *Handler) HandleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !h.validateInternalRequest(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
peers, err := h.ListPeers(r.Context())
|
peers, err := h.ListPeers(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("failed to list WG peers", zap.Error(err))
|
h.logger.Error("failed to list WG peers", zap.Error(err))
|
||||||
@ -147,6 +153,11 @@ func (h *Handler) HandleRemovePeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !h.validateInternalRequest(r) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
nodeID := r.URL.Query().Get("node_id")
|
nodeID := r.URL.Query().Get("node_id")
|
||||||
if nodeID == "" {
|
if nodeID == "" {
|
||||||
http.Error(w, "node_id parameter required", http.StatusBadRequest)
|
http.Error(w, "node_id parameter required", http.StatusBadRequest)
|
||||||
@ -165,6 +176,18 @@ func (h *Handler) HandleRemovePeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.logger.Info("removed WireGuard peer", zap.String("node_id", nodeID))
|
h.logger.Info("removed WireGuard peer", zap.String("node_id", nodeID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateInternalRequest checks that the request comes from a WireGuard peer
|
||||||
|
// and includes a valid cluster secret. Both conditions must be met.
|
||||||
|
func (h *Handler) validateInternalRequest(r *http.Request) bool {
|
||||||
|
if !auth.IsWireGuardPeer(r.RemoteAddr) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h.clusterSecret == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Header.Get("X-Cluster-Secret") == h.clusterSecret
|
||||||
|
}
|
||||||
|
|
||||||
// ListPeers returns all registered WireGuard peers
|
// ListPeers returns all registered WireGuard peers
|
||||||
func (h *Handler) ListPeers(ctx context.Context) ([]PeerRecord, error) {
|
func (h *Handler) ListPeers(ctx context.Context) ([]PeerRecord, error) {
|
||||||
var peers []PeerRecord
|
var peers []PeerRecord
|
||||||
|
|||||||
@ -74,7 +74,11 @@ func (g *Gateway) validateAuthForNamespaceProxy(r *http.Request) (namespace stri
|
|||||||
// lookupAPIKeyNamespace resolves an API key to its namespace using cache and DB.
|
// lookupAPIKeyNamespace resolves an API key to its namespace using cache and DB.
|
||||||
// dbClient controls which database is queried (global vs namespace-specific).
|
// dbClient controls which database is queried (global vs namespace-specific).
|
||||||
// Returns the namespace name or an error if the key is invalid.
|
// Returns the namespace name or an error if the key is invalid.
|
||||||
|
//
|
||||||
|
// Dual lookup strategy for rolling upgrade: tries HMAC-hashed key first (new keys),
|
||||||
|
// then falls back to raw key lookup (existing unhashed keys during transition).
|
||||||
func (g *Gateway) lookupAPIKeyNamespace(ctx context.Context, key string, dbClient client.NetworkClient) (string, error) {
|
func (g *Gateway) lookupAPIKeyNamespace(ctx context.Context, key string, dbClient client.NetworkClient) (string, error) {
|
||||||
|
// Cache uses raw key as cache key (in-memory only, never persisted)
|
||||||
if g.mwCache != nil {
|
if g.mwCache != nil {
|
||||||
if cachedNS, ok := g.mwCache.GetAPIKeyNamespace(key); ok {
|
if cachedNS, ok := g.mwCache.GetAPIKeyNamespace(key); ok {
|
||||||
return cachedNS, nil
|
return cachedNS, nil
|
||||||
@ -84,21 +88,34 @@ func (g *Gateway) lookupAPIKeyNamespace(ctx context.Context, key string, dbClien
|
|||||||
db := dbClient.Database()
|
db := dbClient.Database()
|
||||||
internalCtx := client.WithInternalAuth(ctx)
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1"
|
||||||
res, err := db.Query(internalCtx, q, key)
|
|
||||||
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
|
||||||
return "", fmt.Errorf("invalid API key")
|
|
||||||
}
|
|
||||||
|
|
||||||
ns := getString(res.Rows[0][0])
|
|
||||||
if ns == "" {
|
|
||||||
return "", fmt.Errorf("invalid API key")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Try HMAC-hashed lookup first (new keys stored as hashes)
|
||||||
|
hashedKey := g.authService.HashAPIKey(key)
|
||||||
|
res, err := db.Query(internalCtx, q, hashedKey)
|
||||||
|
if err == nil && res != nil && res.Count > 0 && len(res.Rows) > 0 && len(res.Rows[0]) > 0 {
|
||||||
|
if ns := getString(res.Rows[0][0]); ns != "" {
|
||||||
if g.mwCache != nil {
|
if g.mwCache != nil {
|
||||||
g.mwCache.SetAPIKeyNamespace(key, ns)
|
g.mwCache.SetAPIKeyNamespace(key, ns)
|
||||||
}
|
}
|
||||||
return ns, nil
|
return ns, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try raw key lookup (existing unhashed keys during rolling upgrade)
|
||||||
|
if hashedKey != key {
|
||||||
|
res, err = db.Query(internalCtx, q, key)
|
||||||
|
if err == nil && res != nil && res.Count > 0 && len(res.Rows) > 0 && len(res.Rows[0]) > 0 {
|
||||||
|
if ns := getString(res.Rows[0][0]); ns != "" {
|
||||||
|
if g.mwCache != nil {
|
||||||
|
g.mwCache.SetAPIKeyNamespace(key, ns)
|
||||||
|
}
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("invalid API key")
|
||||||
|
}
|
||||||
|
|
||||||
// isWebSocketUpgrade checks if the request is a WebSocket upgrade request
|
// isWebSocketUpgrade checks if the request is a WebSocket upgrade request
|
||||||
func isWebSocketUpgrade(r *http.Request) bool {
|
func isWebSocketUpgrade(r *http.Request) bool {
|
||||||
|
|||||||
@ -6,13 +6,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wireGuardNet is the WireGuard mesh subnet, parsed once at init.
|
// wireGuardNet is the WireGuard mesh subnet, parsed once at init.
|
||||||
var wireGuardNet *net.IPNet
|
var wireGuardNet *net.IPNet
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_, wireGuardNet, _ = net.ParseCIDR("10.0.0.0/8")
|
_, wireGuardNet, _ = net.ParseCIDR(auth.WireGuardSubnet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimiter implements a token-bucket rate limiter per client IP.
|
// RateLimiter implements a token-bucket rate limiter per client IP.
|
||||||
@ -126,7 +128,7 @@ func (nrl *NamespaceRateLimiter) Allow(namespace string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rateLimitMiddleware returns 429 when a client exceeds the rate limit.
|
// rateLimitMiddleware returns 429 when a client exceeds the rate limit.
|
||||||
// Internal traffic from the WireGuard subnet (10.0.0.0/8) is exempt.
|
// Internal traffic from the WireGuard subnet is exempt.
|
||||||
func (g *Gateway) rateLimitMiddleware(next http.Handler) http.Handler {
|
func (g *Gateway) rateLimitMiddleware(next http.Handler) http.Handler {
|
||||||
if g.rateLimiter == nil {
|
if g.rateLimiter == nil {
|
||||||
return next
|
return next
|
||||||
@ -170,7 +172,7 @@ func (g *Gateway) namespaceRateLimitMiddleware(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isInternalIP returns true if the IP is in the WireGuard 10.0.0.0/8 subnet
|
// isInternalIP returns true if the IP is in the WireGuard subnet
|
||||||
// or is a loopback address.
|
// or is a loopback address.
|
||||||
func isInternalIP(ipStr string) bool {
|
func isInternalIP(ipStr string) bool {
|
||||||
// Strip port if present
|
// Strip port if present
|
||||||
@ -187,6 +189,5 @@ func isInternalIP(ipStr string) bool {
|
|||||||
if ip.IsLoopback() {
|
if ip.IsLoopback() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// 10.0.0.0/8 — WireGuard mesh
|
|
||||||
return wireGuardNet.Contains(ip)
|
return wireGuardNet.Contains(ip)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,7 +98,9 @@ func TestIsInternalIP(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"10.0.0.1", true},
|
{"10.0.0.1", true},
|
||||||
{"10.0.0.254", true},
|
{"10.0.0.254", true},
|
||||||
{"10.255.255.255", true},
|
{"10.0.0.255", true},
|
||||||
|
{"10.0.1.1", false}, // outside /24 — VPS provider's internal range, not our WG mesh
|
||||||
|
{"10.255.255.255", false}, // outside /24
|
||||||
{"127.0.0.1", true},
|
{"127.0.0.1", true},
|
||||||
{"192.168.1.1", false},
|
{"192.168.1.1", false},
|
||||||
{"8.8.8.8", false},
|
{"8.8.8.8", false},
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package ipfs
|
package ipfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -19,6 +20,7 @@ type ClusterConfigManager struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
clusterPath string
|
clusterPath string
|
||||||
secret string
|
secret string
|
||||||
|
trustedPeersPath string // path to ipfs-cluster-trusted-peers file
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClusterConfigManager creates a new IPFS Cluster config manager
|
// NewClusterConfigManager creates a new IPFS Cluster config manager
|
||||||
@ -46,12 +48,14 @@ func NewClusterConfigManager(cfg *config.Config, logger *zap.Logger) (*ClusterCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
secretPath := filepath.Join(dataDir, "..", "cluster-secret")
|
secretPath := filepath.Join(dataDir, "..", "cluster-secret")
|
||||||
|
trustedPeersPath := ""
|
||||||
if strings.Contains(dataDir, ".orama") {
|
if strings.Contains(dataDir, ".orama") {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
secretsDir := filepath.Join(home, ".orama", "secrets")
|
secretsDir := filepath.Join(home, ".orama", "secrets")
|
||||||
if err := os.MkdirAll(secretsDir, 0700); err == nil {
|
if err := os.MkdirAll(secretsDir, 0700); err == nil {
|
||||||
secretPath = filepath.Join(secretsDir, "cluster-secret")
|
secretPath = filepath.Join(secretsDir, "cluster-secret")
|
||||||
|
trustedPeersPath = filepath.Join(secretsDir, "ipfs-cluster-trusted-peers")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,6 +70,7 @@ func NewClusterConfigManager(cfg *config.Config, logger *zap.Logger) (*ClusterCo
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
clusterPath: clusterPath,
|
clusterPath: clusterPath,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
|
trustedPeersPath: trustedPeersPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,7 +119,15 @@ func (cm *ClusterConfigManager) EnsureConfig() error {
|
|||||||
cfg.Cluster.Secret = cm.secret
|
cfg.Cluster.Secret = cm.secret
|
||||||
cfg.Cluster.ListenMultiaddress = []string{fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", clusterListenPort)}
|
cfg.Cluster.ListenMultiaddress = []string{fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", clusterListenPort)}
|
||||||
cfg.Consensus.CRDT.ClusterName = "orama-cluster"
|
cfg.Consensus.CRDT.ClusterName = "orama-cluster"
|
||||||
|
|
||||||
|
// Use trusted peers from file if available, otherwise fall back to "*" (open trust)
|
||||||
|
trustedPeers := cm.loadTrustedPeersWithSelf()
|
||||||
|
if len(trustedPeers) > 0 {
|
||||||
|
cfg.Consensus.CRDT.TrustedPeers = trustedPeers
|
||||||
|
} else {
|
||||||
cfg.Consensus.CRDT.TrustedPeers = []string{"*"}
|
cfg.Consensus.CRDT.TrustedPeers = []string{"*"}
|
||||||
|
}
|
||||||
|
|
||||||
cfg.API.RestAPI.HTTPListenMultiaddress = fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", restAPIPort)
|
cfg.API.RestAPI.HTTPListenMultiaddress = fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", restAPIPort)
|
||||||
cfg.API.IPFSProxy.ListenMultiaddress = fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", proxyPort)
|
cfg.API.IPFSProxy.ListenMultiaddress = fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", proxyPort)
|
||||||
cfg.API.IPFSProxy.NodeMultiaddress = fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", ipfsPort)
|
cfg.API.IPFSProxy.NodeMultiaddress = fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", ipfsPort)
|
||||||
@ -198,3 +211,89 @@ func (cm *ClusterConfigManager) createTemplateConfig() *ClusterServiceConfig {
|
|||||||
cfg.Raw = make(map[string]interface{})
|
cfg.Raw = make(map[string]interface{})
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readClusterPeerID reads this node's IPFS Cluster peer ID from identity.json
|
||||||
|
func (cm *ClusterConfigManager) readClusterPeerID() (string, error) {
|
||||||
|
identityPath := filepath.Join(cm.clusterPath, "identity.json")
|
||||||
|
data, err := os.ReadFile(identityPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read identity.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &identity); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse identity.json: %w", err)
|
||||||
|
}
|
||||||
|
if identity.ID == "" {
|
||||||
|
return "", fmt.Errorf("peer ID not found in identity.json")
|
||||||
|
}
|
||||||
|
return identity.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTrustedPeers reads trusted peer IDs from the trusted-peers file (one per line)
|
||||||
|
func (cm *ClusterConfigManager) loadTrustedPeers() []string {
|
||||||
|
if cm.trustedPeersPath == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(cm.trustedPeersPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var peers []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
peers = append(peers, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTrustedPeer appends a peer ID to the trusted-peers file if not already present
|
||||||
|
func (cm *ClusterConfigManager) addTrustedPeer(peerID string) error {
|
||||||
|
if cm.trustedPeersPath == "" || peerID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existing := cm.loadTrustedPeers()
|
||||||
|
for _, p := range existing {
|
||||||
|
if p == peerID {
|
||||||
|
return nil // already present
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing = append(existing, peerID)
|
||||||
|
return os.WriteFile(cm.trustedPeersPath, []byte(strings.Join(existing, "\n")+"\n"), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTrustedPeersWithSelf loads trusted peers from file and ensures this node's
|
||||||
|
// own peer ID is included. Returns nil if no trusted peers file exists.
|
||||||
|
func (cm *ClusterConfigManager) loadTrustedPeersWithSelf() []string {
|
||||||
|
peers := cm.loadTrustedPeers()
|
||||||
|
|
||||||
|
// Try to read own peer ID and add it
|
||||||
|
ownID, err := cm.readClusterPeerID()
|
||||||
|
if err != nil {
|
||||||
|
cm.logger.Debug("Could not read own IPFS Cluster peer ID", zap.Error(err))
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|
||||||
|
if ownID != "" {
|
||||||
|
if err := cm.addTrustedPeer(ownID); err != nil {
|
||||||
|
cm.logger.Warn("Failed to persist own peer ID to trusted peers file", zap.Error(err))
|
||||||
|
}
|
||||||
|
// Check if already in the list
|
||||||
|
found := false
|
||||||
|
for _, p := range peers {
|
||||||
|
if p == ownID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
peers = append(peers, ownID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return peers
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,11 @@ type ClusterManagerConfig struct {
|
|||||||
IPFSAPIURL string // IPFS API URL (default: "http://localhost:4501")
|
IPFSAPIURL string // IPFS API URL (default: "http://localhost:4501")
|
||||||
IPFSTimeout time.Duration // Timeout for IPFS operations (default: 60s)
|
IPFSTimeout time.Duration // Timeout for IPFS operations (default: 60s)
|
||||||
IPFSReplicationFactor int // IPFS replication factor (default: 3)
|
IPFSReplicationFactor int // IPFS replication factor (default: 3)
|
||||||
|
|
||||||
|
// TurnEncryptionKey is a 32-byte AES-256 key for encrypting TURN shared secrets
|
||||||
|
// in RQLite. Derived from cluster secret via HKDF(clusterSecret, "turn-encryption").
|
||||||
|
// If nil, TURN secrets are stored in plaintext (backward compatibility).
|
||||||
|
TurnEncryptionKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterManager orchestrates namespace cluster provisioning and lifecycle
|
// ClusterManager orchestrates namespace cluster provisioning and lifecycle
|
||||||
@ -58,6 +63,9 @@ type ClusterManager struct {
|
|||||||
// Local node identity for distributed spawning
|
// Local node identity for distributed spawning
|
||||||
localNodeID string
|
localNodeID string
|
||||||
|
|
||||||
|
// AES-256 key for encrypting TURN secrets in RQLite (nil = plaintext)
|
||||||
|
turnEncryptionKey []byte
|
||||||
|
|
||||||
// Track provisioning operations
|
// Track provisioning operations
|
||||||
provisioningMu sync.RWMutex
|
provisioningMu sync.RWMutex
|
||||||
provisioning map[string]bool // namespace -> in progress
|
provisioning map[string]bool // namespace -> in progress
|
||||||
@ -108,6 +116,7 @@ func NewClusterManager(
|
|||||||
ipfsAPIURL: ipfsAPIURL,
|
ipfsAPIURL: ipfsAPIURL,
|
||||||
ipfsTimeout: ipfsTimeout,
|
ipfsTimeout: ipfsTimeout,
|
||||||
ipfsReplicationFactor: ipfsReplicationFactor,
|
ipfsReplicationFactor: ipfsReplicationFactor,
|
||||||
|
turnEncryptionKey: cfg.TurnEncryptionKey,
|
||||||
logger: logger.With(zap.String("component", "cluster-manager")),
|
logger: logger.With(zap.String("component", "cluster-manager")),
|
||||||
provisioning: make(map[string]bool),
|
provisioning: make(map[string]bool),
|
||||||
}
|
}
|
||||||
@ -154,6 +163,7 @@ func NewClusterManagerWithComponents(
|
|||||||
ipfsAPIURL: ipfsAPIURL,
|
ipfsAPIURL: ipfsAPIURL,
|
||||||
ipfsTimeout: ipfsTimeout,
|
ipfsTimeout: ipfsTimeout,
|
||||||
ipfsReplicationFactor: ipfsReplicationFactor,
|
ipfsReplicationFactor: ipfsReplicationFactor,
|
||||||
|
turnEncryptionKey: cfg.TurnEncryptionKey,
|
||||||
logger: logger.With(zap.String("component", "cluster-manager")),
|
logger: logger.With(zap.String("component", "cluster-manager")),
|
||||||
provisioning: make(map[string]bool),
|
provisioning: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/client"
|
"github.com/DeBrosOfficial/network/pkg/client"
|
||||||
"github.com/DeBrosOfficial/network/pkg/gateway"
|
"github.com/DeBrosOfficial/network/pkg/gateway"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/secrets"
|
||||||
"github.com/DeBrosOfficial/network/pkg/sfu"
|
"github.com/DeBrosOfficial/network/pkg/sfu"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@ -51,13 +52,23 @@ func (cm *ClusterManager) EnableWebRTC(ctx context.Context, namespaceName, enabl
|
|||||||
}
|
}
|
||||||
turnSecret := base64.StdEncoding.EncodeToString(secretBytes)
|
turnSecret := base64.StdEncoding.EncodeToString(secretBytes)
|
||||||
|
|
||||||
|
// Encrypt TURN secret before storing in RQLite
|
||||||
|
storedSecret := turnSecret
|
||||||
|
if cm.turnEncryptionKey != nil {
|
||||||
|
encrypted, encErr := secrets.Encrypt(turnSecret, cm.turnEncryptionKey)
|
||||||
|
if encErr != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt TURN secret: %w", encErr)
|
||||||
|
}
|
||||||
|
storedSecret = encrypted
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Insert namespace_webrtc_config
|
// 4. Insert namespace_webrtc_config
|
||||||
webrtcConfigID := uuid.New().String()
|
webrtcConfigID := uuid.New().String()
|
||||||
_, err = cm.db.Exec(internalCtx,
|
_, err = cm.db.Exec(internalCtx,
|
||||||
`INSERT INTO namespace_webrtc_config (id, namespace_cluster_id, namespace_name, enabled, turn_shared_secret, turn_credential_ttl, sfu_node_count, turn_node_count, enabled_by, enabled_at)
|
`INSERT INTO namespace_webrtc_config (id, namespace_cluster_id, namespace_name, enabled, turn_shared_secret, turn_credential_ttl, sfu_node_count, turn_node_count, enabled_by, enabled_at)
|
||||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?)`,
|
||||||
webrtcConfigID, cluster.ID, namespaceName,
|
webrtcConfigID, cluster.ID, namespaceName,
|
||||||
turnSecret, DefaultTURNCredentialTTL,
|
storedSecret, DefaultTURNCredentialTTL,
|
||||||
DefaultSFUNodeCount, DefaultTURNNodeCount,
|
DefaultSFUNodeCount, DefaultTURNNodeCount,
|
||||||
enabledBy, time.Now(),
|
enabledBy, time.Now(),
|
||||||
)
|
)
|
||||||
@ -297,6 +308,7 @@ func (cm *ClusterManager) DisableWebRTC(ctx context.Context, namespaceName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetWebRTCConfig returns the WebRTC configuration for a namespace.
|
// GetWebRTCConfig returns the WebRTC configuration for a namespace.
|
||||||
|
// Transparently decrypts the TURN shared secret if it was encrypted at rest.
|
||||||
func (cm *ClusterManager) GetWebRTCConfig(ctx context.Context, namespaceName string) (*WebRTCConfig, error) {
|
func (cm *ClusterManager) GetWebRTCConfig(ctx context.Context, namespaceName string) (*WebRTCConfig, error) {
|
||||||
internalCtx := client.WithInternalAuth(ctx)
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
|
|
||||||
@ -309,6 +321,16 @@ func (cm *ClusterManager) GetWebRTCConfig(ctx context.Context, namespaceName str
|
|||||||
if len(configs) == 0 {
|
if len(configs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt TURN secret if encrypted (handles plaintext passthrough for backward compat)
|
||||||
|
if cm.turnEncryptionKey != nil && secrets.IsEncrypted(configs[0].TURNSharedSecret) {
|
||||||
|
decrypted, decErr := secrets.Decrypt(configs[0].TURNSharedSecret, cm.turnEncryptionKey)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt TURN secret: %w", decErr)
|
||||||
|
}
|
||||||
|
configs[0].TURNSharedSecret = decrypted
|
||||||
|
}
|
||||||
|
|
||||||
return &configs[0], nil
|
return &configs[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/gateway"
|
"github.com/DeBrosOfficial/network/pkg/gateway"
|
||||||
@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
"github.com/DeBrosOfficial/network/pkg/ipfs"
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||||
"github.com/DeBrosOfficial/network/pkg/namespace"
|
"github.com/DeBrosOfficial/network/pkg/namespace"
|
||||||
|
"github.com/DeBrosOfficial/network/pkg/secrets"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -44,6 +46,18 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
|
|||||||
clusterSecret = string(secretBytes)
|
clusterSecret = string(secretBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read API key HMAC secret for hashing API keys before storage
|
||||||
|
apiKeyHMACSecret := ""
|
||||||
|
if secretBytes, err := os.ReadFile(filepath.Join(oramaDir, "secrets", "api-key-hmac-secret")); err == nil {
|
||||||
|
apiKeyHMACSecret = strings.TrimSpace(string(secretBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read RQLite credentials for authenticated DB connections
|
||||||
|
rqlitePassword := ""
|
||||||
|
if secretBytes, err := os.ReadFile(filepath.Join(oramaDir, "secrets", "rqlite-password")); err == nil {
|
||||||
|
rqlitePassword = strings.TrimSpace(string(secretBytes))
|
||||||
|
}
|
||||||
|
|
||||||
gwCfg := &gateway.Config{
|
gwCfg := &gateway.Config{
|
||||||
ListenAddr: n.config.HTTPGateway.ListenAddr,
|
ListenAddr: n.config.HTTPGateway.ListenAddr,
|
||||||
ClientNamespace: n.config.HTTPGateway.ClientNamespace,
|
ClientNamespace: n.config.HTTPGateway.ClientNamespace,
|
||||||
@ -57,7 +71,10 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
|
|||||||
IPFSTimeout: n.config.HTTPGateway.IPFSTimeout,
|
IPFSTimeout: n.config.HTTPGateway.IPFSTimeout,
|
||||||
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
||||||
DataDir: oramaDir,
|
DataDir: oramaDir,
|
||||||
|
RQLiteUsername: "orama",
|
||||||
|
RQLitePassword: rqlitePassword,
|
||||||
ClusterSecret: clusterSecret,
|
ClusterSecret: clusterSecret,
|
||||||
|
APIKeyHMACSecret: apiKeyHMACSecret,
|
||||||
WebRTCEnabled: n.config.HTTPGateway.WebRTC.Enabled,
|
WebRTCEnabled: n.config.HTTPGateway.WebRTC.Enabled,
|
||||||
SFUPort: n.config.HTTPGateway.WebRTC.SFUPort,
|
SFUPort: n.config.HTTPGateway.WebRTC.SFUPort,
|
||||||
TURNDomain: n.config.HTTPGateway.WebRTC.TURNDomain,
|
TURNDomain: n.config.HTTPGateway.WebRTC.TURNDomain,
|
||||||
@ -73,6 +90,14 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
|
|||||||
// Wire up ClusterManager for per-namespace cluster provisioning
|
// Wire up ClusterManager for per-namespace cluster provisioning
|
||||||
if ormClient := apiGateway.GetORMClient(); ormClient != nil {
|
if ormClient := apiGateway.GetORMClient(); ormClient != nil {
|
||||||
baseDataDir := filepath.Join(os.ExpandEnv(n.config.Node.DataDir), "..", "data", "namespaces")
|
baseDataDir := filepath.Join(os.ExpandEnv(n.config.Node.DataDir), "..", "data", "namespaces")
|
||||||
|
// Derive TURN encryption key from cluster secret (nil if no secret available)
|
||||||
|
var turnEncKey []byte
|
||||||
|
if clusterSecret != "" {
|
||||||
|
if key, keyErr := secrets.DeriveKey(clusterSecret, "turn-encryption"); keyErr == nil {
|
||||||
|
turnEncKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clusterCfg := namespace.ClusterManagerConfig{
|
clusterCfg := namespace.ClusterManagerConfig{
|
||||||
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
BaseDomain: n.config.HTTPGateway.BaseDomain,
|
||||||
BaseDataDir: baseDataDir,
|
BaseDataDir: baseDataDir,
|
||||||
@ -81,6 +106,7 @@ func (n *Node) startHTTPGateway(ctx context.Context) error {
|
|||||||
IPFSAPIURL: gwCfg.IPFSAPIURL,
|
IPFSAPIURL: gwCfg.IPFSAPIURL,
|
||||||
IPFSTimeout: gwCfg.IPFSTimeout,
|
IPFSTimeout: gwCfg.IPFSTimeout,
|
||||||
IPFSReplicationFactor: n.config.Database.IPFS.ReplicationFactor,
|
IPFSReplicationFactor: n.config.Database.IPFS.ReplicationFactor,
|
||||||
|
TurnEncryptionKey: turnEncKey,
|
||||||
}
|
}
|
||||||
clusterManager := namespace.NewClusterManager(ormClient, clusterCfg, n.logger.Logger)
|
clusterManager := namespace.NewClusterManager(ormClient, clusterCfg, n.logger.Logger)
|
||||||
clusterManager.SetLocalNodeID(gwCfg.NodePeerID)
|
clusterManager.SetLocalNodeID(gwCfg.NodePeerID)
|
||||||
|
|||||||
@ -16,8 +16,13 @@ type RQLiteAdapter struct {
|
|||||||
|
|
||||||
// NewRQLiteAdapter creates a new adapter that provides sql.DB interface for RQLite
|
// NewRQLiteAdapter creates a new adapter that provides sql.DB interface for RQLite
|
||||||
func NewRQLiteAdapter(manager *RQLiteManager) (*RQLiteAdapter, error) {
|
func NewRQLiteAdapter(manager *RQLiteManager) (*RQLiteAdapter, error) {
|
||||||
// Use the gorqlite database/sql driver
|
// Build DSN with optional basic auth credentials
|
||||||
db, err := sql.Open("rqlite", fmt.Sprintf("http://localhost:%d?disableClusterDiscovery=true&level=none", manager.config.RQLitePort))
|
dsn := fmt.Sprintf("http://localhost:%d?disableClusterDiscovery=true&level=none", manager.config.RQLitePort)
|
||||||
|
if manager.config.RQLiteUsername != "" && manager.config.RQLitePassword != "" {
|
||||||
|
dsn = fmt.Sprintf("http://%s:%s@localhost:%d?disableClusterDiscovery=true&level=none",
|
||||||
|
manager.config.RQLiteUsername, manager.config.RQLitePassword, manager.config.RQLitePort)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("rqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open RQLite SQL connection: %w", err)
|
return nil, fmt.Errorf("failed to open RQLite SQL connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type InstanceConfig struct {
|
|||||||
JoinAddresses []string // Addresses to join (e.g., ["192.168.1.2:10001"])
|
JoinAddresses []string // Addresses to join (e.g., ["192.168.1.2:10001"])
|
||||||
DataDir string // Data directory for this instance
|
DataDir string // Data directory for this instance
|
||||||
IsLeader bool // Whether this is the first node (creates cluster)
|
IsLeader bool // Whether this is the first node (creates cluster)
|
||||||
|
AuthFile string // Path to RQLite auth JSON file. Empty = no auth enforcement.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance represents a running RQLite instance
|
// Instance represents a running RQLite instance
|
||||||
@ -91,6 +92,11 @@ func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig
|
|||||||
"-raft-leader-lease-timeout", "2s",
|
"-raft-leader-lease-timeout", "2s",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RQLite HTTP Basic Auth
|
||||||
|
if cfg.AuthFile != "" {
|
||||||
|
args = append(args, "-auth", cfg.AuthFile)
|
||||||
|
}
|
||||||
|
|
||||||
// Add join addresses if not the leader (must be before data directory)
|
// Add join addresses if not the leader (must be before data directory)
|
||||||
if !cfg.IsLeader && len(cfg.JoinAddresses) > 0 {
|
if !cfg.IsLeader && len(cfg.JoinAddresses) > 0 {
|
||||||
for _, addr := range cfg.JoinAddresses {
|
for _, addr := range cfg.JoinAddresses {
|
||||||
|
|||||||
@ -137,6 +137,13 @@ func (r *RQLiteManager) launchProcess(ctx context.Context, rqliteDataDir string)
|
|||||||
"-raft-leader-lease-timeout", raftLeaderLease.String(),
|
"-raft-leader-lease-timeout", raftLeaderLease.String(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RQLite HTTP Basic Auth — when auth file exists, enforce authentication
|
||||||
|
if r.config.RQLiteAuthFile != "" {
|
||||||
|
r.logger.Info("Enabling RQLite HTTP Basic Auth",
|
||||||
|
zap.String("auth_file", r.config.RQLiteAuthFile))
|
||||||
|
args = append(args, "-auth", r.config.RQLiteAuthFile)
|
||||||
|
}
|
||||||
|
|
||||||
if r.config.RQLiteJoinAddress != "" && !r.hasExistingState(rqliteDataDir) {
|
if r.config.RQLiteJoinAddress != "" && !r.hasExistingState(rqliteDataDir) {
|
||||||
r.logger.Info("First-time join to RQLite cluster", zap.String("join_address", r.config.RQLiteJoinAddress))
|
r.logger.Info("First-time join to RQLite cluster", zap.String("join_address", r.config.RQLiteJoinAddress))
|
||||||
|
|
||||||
|
|||||||
98
pkg/secrets/encrypt.go
Normal file
98
pkg/secrets/encrypt.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Package secrets provides application-level encryption for sensitive data stored in RQLite.
|
||||||
|
// Uses AES-256-GCM with HKDF key derivation from the cluster secret.
|
||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix for encrypted values to distinguish from plaintext during migration.
|
||||||
|
const encryptedPrefix = "enc:"
|
||||||
|
|
||||||
|
// DeriveKey derives a 32-byte AES-256 key from the cluster secret using HKDF-SHA256.
|
||||||
|
// The purpose string provides domain separation (e.g., "turn-encryption").
|
||||||
|
func DeriveKey(clusterSecret, purpose string) ([]byte, error) {
|
||||||
|
if clusterSecret == "" {
|
||||||
|
return nil, fmt.Errorf("cluster secret is empty")
|
||||||
|
}
|
||||||
|
reader := hkdf.New(sha256.New, []byte(clusterSecret), nil, []byte(purpose))
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(reader, key); err != nil {
|
||||||
|
return nil, fmt.Errorf("HKDF key derivation failed: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext with AES-256-GCM using the given key.
|
||||||
|
// Returns a base64-encoded string prefixed with "enc:" for identification.
|
||||||
|
func Encrypt(plaintext string, key []byte) (string, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonce is prepended to ciphertext
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts an "enc:"-prefixed ciphertext string with AES-256-GCM.
|
||||||
|
// If the input is not prefixed with "enc:", it is returned as-is (plaintext passthrough
|
||||||
|
// for backward compatibility during migration).
|
||||||
|
func Decrypt(ciphertext string, key []byte) (string, error) {
|
||||||
|
if !strings.HasPrefix(ciphertext, encryptedPrefix) {
|
||||||
|
return ciphertext, nil // plaintext passthrough
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(ciphertext, encryptedPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, sealed := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, sealed, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decryption failed (wrong key or corrupted data): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEncrypted returns true if the value has the "enc:" prefix.
|
||||||
|
func IsEncrypted(value string) bool {
|
||||||
|
return strings.HasPrefix(value, encryptedPrefix)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user