mirror of
https://github.com/DeBrosOfficial/network.git
synced 2025-10-06 12:29:07 +00:00
Merge pull request 'feat: implement HTTP gateway with auth, storage, and namespace isolation' (#5) from gateway into main
Reviewed-on: #5
This commit is contained in:
commit
8e5b91dee0
119
AI_CONTEXT.md
119
AI_CONTEXT.md
@ -9,6 +9,7 @@
|
|||||||
- [Configuration System](#configuration-system)
|
- [Configuration System](#configuration-system)
|
||||||
- [Node vs Client Roles](#node-vs-client-roles)
|
- [Node vs Client Roles](#node-vs-client-roles)
|
||||||
- [Network Protocol & Data Flow](#network-protocol--data-flow)
|
- [Network Protocol & Data Flow](#network-protocol--data-flow)
|
||||||
|
- [Gateway Service](#gateway-service)
|
||||||
- [Build & Development](#build--development)
|
- [Build & Development](#build--development)
|
||||||
- [API Reference](#api-reference)
|
- [API Reference](#api-reference)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
@ -109,6 +110,7 @@ network/
|
|||||||
### 4. **Database Layer (`pkg/database/`)**
|
### 4. **Database Layer (`pkg/database/`)**
|
||||||
- **RQLite:** Distributed SQLite with Raft consensus, automatic leader election, and failover.
|
- **RQLite:** Distributed SQLite with Raft consensus, automatic leader election, and failover.
|
||||||
- **Client API:** SQL queries, transactions, schema management.
|
- **Client API:** SQL queries, transactions, schema management.
|
||||||
|
- **Migration System:** Robust database migration handling with automatic schema versioning, SQL statement processing, and error recovery. Supports complex SQL files with comments and multiple statements.
|
||||||
|
|
||||||
### 5. **Storage System (`pkg/storage/`)**
|
### 5. **Storage System (`pkg/storage/`)**
|
||||||
- **Distributed KV:** Namespace-isolated, CRUD operations, prefix queries, replication.
|
- **Distributed KV:** Namespace-isolated, CRUD operations, prefix queries, replication.
|
||||||
@ -179,6 +181,108 @@ network/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Gateway Service
|
||||||
|
|
||||||
|
The Gateway provides an HTTP(S)/WebSocket surface over the network client with strict namespace enforcement.
|
||||||
|
|
||||||
|
- **Run:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-gateway
|
||||||
|
# Env overrides: GATEWAY_ADDR, GATEWAY_NAMESPACE, GATEWAY_BOOTSTRAP_PEERS,
|
||||||
|
# GATEWAY_REQUIRE_AUTH, GATEWAY_API_KEYS
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Enhanced Authentication System:** The gateway features an improved authentication system with automatic wallet detection and multi-wallet support:
|
||||||
|
- **Automatic Authentication:** No manual auth command required - authentication happens automatically when needed
|
||||||
|
- **Multi-Wallet Support:** Users can manage multiple wallet credentials seamlessly
|
||||||
|
- **JWT Authentication:** Issued by this gateway; JWKS available at `GET /v1/auth/jwks` or `/.well-known/jwks.json`
|
||||||
|
- **API Key Support:** Via `Authorization: Bearer <key>` or `X-API-Key`, optionally mapped to a namespace
|
||||||
|
- **Wallet Verification:** Uses Ethereum EIP-191 `personal_sign` with enhanced flow:
|
||||||
|
- `POST /v1/auth/challenge` returns `{nonce}` (public endpoint with internal auth context)
|
||||||
|
- `POST /v1/auth/verify` expects `{wallet, nonce, signature}` with 65-byte r||s||v hex (0x allowed)
|
||||||
|
- `v` normalized (27/28 or 0/1), address match is case-insensitive
|
||||||
|
- Nonce is marked used only after successful verification
|
||||||
|
- Supports automatic wallet switching and credential persistence
|
||||||
|
|
||||||
|
- **Namespace Enforcement:** Storage and PubSub are internally prefixed `ns::<namespace>::...`. Ownership of namespace is enforced by middleware for routes under `/v1/storage*`, `/v1/apps*`, and `/v1/pubsub*`.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
- Health/Version
|
||||||
|
- `GET /health`, `GET /v1/health`
|
||||||
|
- `GET /v1/status`
|
||||||
|
- `GET /v1/version` → `{version, commit, build_time, started_at, uptime}`
|
||||||
|
|
||||||
|
- JWKS
|
||||||
|
- `GET /v1/auth/jwks`
|
||||||
|
- `GET /.well-known/jwks.json`
|
||||||
|
|
||||||
|
- Auth (Enhanced Multi-Wallet System)
|
||||||
|
- `POST /v1/auth/challenge` (public endpoint, generates wallet challenge)
|
||||||
|
- `POST /v1/auth/verify` (public endpoint, verifies wallet signature)
|
||||||
|
- `POST /v1/auth/register` (public endpoint, wallet registration)
|
||||||
|
- `POST /v1/auth/refresh` (public endpoint, token refresh)
|
||||||
|
- `POST /v1/auth/logout` (clears authentication state)
|
||||||
|
- `GET /v1/auth/whoami` (returns current auth status)
|
||||||
|
- `POST /v1/auth/api-key` (generates API keys for authenticated users)
|
||||||
|
|
||||||
|
- Storage
|
||||||
|
- `POST /v1/storage/get`, `POST /v1/storage/put`, `POST /v1/storage/delete`
|
||||||
|
- `GET /v1/storage/list?prefix=...`, `GET /v1/storage/exists?key=...`
|
||||||
|
|
||||||
|
- Network
|
||||||
|
- `GET /v1/network/status`, `GET /v1/network/peers`
|
||||||
|
- `POST /v1/network/connect`, `POST /v1/network/disconnect`
|
||||||
|
|
||||||
|
### PubSub
|
||||||
|
|
||||||
|
- WebSocket
|
||||||
|
- `GET /v1/pubsub/ws?topic=<topic>`
|
||||||
|
- Server sends messages as binary frames; 30s ping keepalive.
|
||||||
|
- Client text/binary frames are published to the same namespaced topic.
|
||||||
|
|
||||||
|
- REST
|
||||||
|
- `POST /v1/pubsub/publish` → body `{topic, data_base64}` → `{status:"ok"}`
|
||||||
|
- `GET /v1/pubsub/topics` → `{topics:["<topic>", ...]}` (names trimmed to caller namespace)
|
||||||
|
|
||||||
|
### Authentication Improvements
|
||||||
|
|
||||||
|
The gateway authentication system has been significantly enhanced with the following features:
|
||||||
|
|
||||||
|
- **Database Migration System:** Robust SQL migration handling with proper statement processing and error handling
|
||||||
|
- **Automatic Wallet Detection:** CLI automatically detects and manages wallet credentials without manual auth commands
|
||||||
|
- **Multi-Wallet Management:** Support for multiple wallet credentials with automatic switching
|
||||||
|
- **Enhanced User Experience:** Streamlined authentication flow with credential persistence
|
||||||
|
- **Internal Auth Context:** Public authentication endpoints use internal auth context to prevent circular dependencies
|
||||||
|
- **Improved Error Handling:** Better error messages and debugging information for authentication issues
|
||||||
|
|
||||||
|
Security note: CORS and WS origin checks are permissive for development; harden for production. The enhanced authentication system maintains security while improving accessibility and user experience.
|
||||||
|
|
||||||
|
### Database Migration System
|
||||||
|
|
||||||
|
The gateway includes an enhanced database migration system with the following features:
|
||||||
|
|
||||||
|
- **Automatic Schema Management:** Database schema is automatically initialized and updated using migration files
|
||||||
|
- **Robust SQL Processing:** Handles complex SQL files with comments, multiple statements, and proper statement termination
|
||||||
|
- **Version Control:** Tracks migration versions to prevent duplicate execution and ensure proper upgrade paths
|
||||||
|
- **Error Recovery:** Comprehensive error handling with detailed logging for debugging migration issues
|
||||||
|
- **Transaction Safety:** Each migration runs in a transaction to ensure atomicity and data integrity
|
||||||
|
- **SQL File Support:** Supports standard SQL migration files with `.sql` extension in the `migrations/` directory
|
||||||
|
|
||||||
|
**Migration File Structure:**
|
||||||
|
```
|
||||||
|
migrations/
|
||||||
|
├── 001_initial_schema.sql # Initial database setup
|
||||||
|
├── 002_add_auth_tables.sql # Authentication tables
|
||||||
|
├── 003_add_indexes.sql # Performance indexes
|
||||||
|
└── ... # Additional migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration system automatically processes SQL statements, handles comments, and ensures proper execution order during gateway startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Build & Development
|
## Build & Development
|
||||||
|
|
||||||
### **Prerequisites**
|
### **Prerequisites**
|
||||||
@ -192,6 +296,7 @@ network/
|
|||||||
make build # Build all executables
|
make build # Build all executables
|
||||||
make test # Run tests
|
make test # Run tests
|
||||||
make run-node # Start node (auto-detects bootstrap vs regular)
|
make run-node # Start node (auto-detects bootstrap vs regular)
|
||||||
|
make run-gateway # Start HTTP gateway (env overrides supported)
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Development Workflow**
|
### **Development Workflow**
|
||||||
@ -248,10 +353,24 @@ peers, err := client.Network().GetPeers(ctx)
|
|||||||
- **Message Delivery Failures:** Verify topic names, subscription status, and network connectivity.
|
- **Message Delivery Failures:** Verify topic names, subscription status, and network connectivity.
|
||||||
- **High Memory Usage:** Unsubscribe from topics when done, monitor connection pool size.
|
- **High Memory Usage:** Unsubscribe from topics when done, monitor connection pool size.
|
||||||
|
|
||||||
|
### **Authentication Issues**
|
||||||
|
- **Wallet Connection Failed:** Check wallet signature format (65-byte r||s||v hex), ensure nonce matches exactly, verify wallet address case-insensitivity.
|
||||||
|
- **JWT Token Expired:** Use refresh endpoint or re-authenticate with wallet.
|
||||||
|
- **API Key Invalid:** Verify key format and namespace mapping in gateway configuration.
|
||||||
|
- **Multi-Wallet Conflicts:** Clear credential cache and re-authenticate: `rm -rf ~/.debros/credentials`
|
||||||
|
- **Circular Auth Dependencies:** Ensure public auth endpoints use internal auth context.
|
||||||
|
|
||||||
|
### **Database Migration Issues**
|
||||||
|
- **Migration Failures:** Check SQL syntax, ensure proper statement termination, review migration logs.
|
||||||
|
- **Version Conflicts:** Verify migration file naming and sequential order.
|
||||||
|
- **Incomplete Migrations:** Check for transaction rollbacks and database locks.
|
||||||
|
|
||||||
### **Debugging**
|
### **Debugging**
|
||||||
- Enable debug logging: `export LOG_LEVEL=debug`
|
- Enable debug logging: `export LOG_LEVEL=debug`
|
||||||
- Check service logs: `sudo journalctl -u debros-node.service -f`
|
- Check service logs: `sudo journalctl -u debros-node.service -f`
|
||||||
- Use CLI for health and peer checks: `./bin/network-cli health`, `./bin/network-cli peers`
|
- Use CLI for health and peer checks: `./bin/network-cli health`, `./bin/network-cli peers`
|
||||||
|
- Check gateway logs: `sudo journalctl -u debros-gateway.service -f`
|
||||||
|
- Test authentication flow: `./bin/network-cli storage put test-key test-value`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
21
Makefile
21
Makefile
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports
|
.PHONY: build clean test run-node run-node2 run-node3 run-example deps tidy fmt vet lint clear-ports
|
||||||
|
|
||||||
VERSION := 0.19.0-beta
|
VERSION := 0.34.0-beta
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
||||||
@ -14,6 +14,8 @@ build: deps
|
|||||||
@mkdir -p bin
|
@mkdir -p bin
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/node ./cmd/node
|
go build -ldflags "$(LDFLAGS)" -o bin/node ./cmd/node
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/network-cli cmd/cli/main.go
|
go build -ldflags "$(LDFLAGS)" -o bin/network-cli cmd/cli/main.go
|
||||||
|
# Inject gateway build metadata via pkg path variables
|
||||||
|
go build -ldflags "$(LDFLAGS) -X 'git.debros.io/DeBros/network/pkg/gateway.BuildVersion=$(VERSION)' -X 'git.debros.io/DeBros/network/pkg/gateway.BuildCommit=$(COMMIT)' -X 'git.debros.io/DeBros/network/pkg/gateway.BuildTime=$(DATE)'" -o bin/gateway ./cmd/gateway
|
||||||
@echo "Build complete! Run ./bin/network-cli version"
|
@echo "Build complete! Run ./bin/network-cli version"
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
@ -45,6 +47,22 @@ run-node3:
|
|||||||
@echo "Starting regular node3 with config..."
|
@echo "Starting regular node3 with config..."
|
||||||
go run ./cmd/node --config configs/node.yaml
|
go run ./cmd/node --config configs/node.yaml
|
||||||
|
|
||||||
|
# Run gateway HTTP server
|
||||||
|
# Usage examples:
|
||||||
|
# make run-gateway # uses defaults (:8080, namespace=default)
|
||||||
|
# GATEWAY_ADDR=":8081" make run-gateway # override listen addr via env
|
||||||
|
# GATEWAY_NAMESPACE=myapp make run-gateway # set namespace
|
||||||
|
# GATEWAY_BOOTSTRAP_PEERS="/ip4/127.0.0.1/tcp/4001/p2p/<ID>" make run-gateway
|
||||||
|
# GATEWAY_REQUIRE_AUTH=1 GATEWAY_API_KEYS="key1:ns1,key2:ns2" make run-gateway
|
||||||
|
run-gateway:
|
||||||
|
@echo "Starting gateway HTTP server..."
|
||||||
|
GATEWAY_ADDR=$(or $(ADDR),$(GATEWAY_ADDR)) \
|
||||||
|
GATEWAY_NAMESPACE=$(or $(NAMESPACE),$(GATEWAY_NAMESPACE)) \
|
||||||
|
GATEWAY_BOOTSTRAP_PEERS=$(GATEWAY_BOOTSTRAP_PEERS) \
|
||||||
|
GATEWAY_REQUIRE_AUTH=$(GATEWAY_REQUIRE_AUTH) \
|
||||||
|
GATEWAY_API_KEYS=$(GATEWAY_API_KEYS) \
|
||||||
|
go run ./cmd/gateway
|
||||||
|
|
||||||
# Run basic usage example
|
# Run basic usage example
|
||||||
run-example:
|
run-example:
|
||||||
@echo "Running basic usage example..."
|
@echo "Running basic usage example..."
|
||||||
@ -170,6 +188,7 @@ help:
|
|||||||
@echo " run-node - Start bootstrap node"
|
@echo " run-node - Start bootstrap node"
|
||||||
@echo " run-node2 - Start second node (requires JOINADDR, optional HTTP/RAFT/P2P)"
|
@echo " run-node2 - Start second node (requires JOINADDR, optional HTTP/RAFT/P2P)"
|
||||||
@echo " run-node3 - Start third node (requires JOINADDR, optional HTTP/RAFT/P2P)"
|
@echo " run-node3 - Start third node (requires JOINADDR, optional HTTP/RAFT/P2P)"
|
||||||
|
@echo " run-gateway - Start HTTP gateway (flags via env: GATEWAY_ADDR, GATEWAY_NAMESPACE, GATEWAY_BOOTSTRAP_PEERS, GATEWAY_REQUIRE_AUTH, GATEWAY_API_KEYS)"
|
||||||
@echo " run-example - Run usage example"
|
@echo " run-example - Run usage example"
|
||||||
@echo " run-cli - Run network CLI help"
|
@echo " run-cli - Run network CLI help"
|
||||||
@echo " show-bootstrap - Show example bootstrap usage with flags"
|
@echo " show-bootstrap - Show example bootstrap usage with flags"
|
||||||
|
226
README.md
226
README.md
@ -13,6 +13,7 @@ A robust, decentralized peer-to-peer network built in Go, providing distributed
|
|||||||
- [Deployment & Installation](#deployment--installation)
|
- [Deployment & Installation](#deployment--installation)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [CLI Usage](#cli-usage)
|
- [CLI Usage](#cli-usage)
|
||||||
|
- [HTTP Gateway](#http-gateway)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
@ -316,9 +317,191 @@ logging:
|
|||||||
--disable-anonrc # Disable anonymous routing (Tor/SOCKS5)
|
--disable-anonrc # Disable anonymous routing (Tor/SOCKS5)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
The CLI features an enhanced authentication system with automatic wallet detection and multi-wallet support:
|
||||||
|
|
||||||
|
- **Automatic Authentication:** No manual auth commands required - authentication happens automatically when operations need credentials
|
||||||
|
- **Multi-Wallet Management:** Seamlessly switch between multiple wallet credentials
|
||||||
|
- **Persistent Sessions:** Wallet credentials are automatically saved and restored between sessions
|
||||||
|
- **Enhanced User Experience:** Streamlined authentication flow with better error handling and user feedback
|
||||||
|
|
||||||
|
When using operations that require authentication (storage, database, pubsub), the CLI will automatically:
|
||||||
|
1. Check for existing valid credentials
|
||||||
|
2. Prompt for wallet authentication if needed
|
||||||
|
3. Handle signature verification
|
||||||
|
4. Persist credentials for future use
|
||||||
|
|
||||||
|
**Example with automatic authentication:**
|
||||||
|
```bash
|
||||||
|
# First time - will prompt for wallet authentication
|
||||||
|
./bin/network-cli storage put user:123 "John Doe"
|
||||||
|
|
||||||
|
# Subsequent calls - uses saved credentials automatically
|
||||||
|
./bin/network-cli storage get user:123
|
||||||
|
./bin/network-cli pubsub publish notifications "Hello World"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Gateway
|
||||||
|
|
||||||
|
The DeBros Network includes a powerful HTTP/WebSocket gateway that provides a modern REST API and WebSocket interface over the P2P network, featuring an enhanced authentication system with multi-wallet support.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make run-gateway
|
||||||
|
# Or manually:
|
||||||
|
go run ./cmd/gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The gateway can be configured via environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic Configuration
|
||||||
|
export GATEWAY_ADDR="0.0.0.0:8080"
|
||||||
|
export GATEWAY_NAMESPACE="my-app"
|
||||||
|
export GATEWAY_BOOTSTRAP_PEERS="/ip4/127.0.0.1/tcp/4001/p2p/YOUR_PEER_ID"
|
||||||
|
|
||||||
|
# Authentication Configuration
|
||||||
|
export GATEWAY_REQUIRE_AUTH=true
|
||||||
|
export GATEWAY_API_KEYS="key1:namespace1,key2:namespace2"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Authentication System
|
||||||
|
|
||||||
|
The gateway features a significantly improved authentication system with the following capabilities:
|
||||||
|
|
||||||
|
#### Key Features
|
||||||
|
- **Automatic Authentication:** No manual auth commands required - authentication happens automatically when needed
|
||||||
|
- **Multi-Wallet Support:** Seamlessly manage multiple wallet credentials with automatic switching
|
||||||
|
- **Persistent Sessions:** Wallet credentials are automatically saved and restored
|
||||||
|
- **Enhanced User Experience:** Streamlined authentication flow with better error handling
|
||||||
|
|
||||||
|
#### Authentication Methods
|
||||||
|
|
||||||
|
**Wallet-Based Authentication (Ethereum EIP-191)**
|
||||||
|
- Uses `personal_sign` for secure wallet verification
|
||||||
|
- Supports multiple wallets with automatic detection
|
||||||
|
- Addresses are case-insensitive with normalized signature handling
|
||||||
|
|
||||||
|
**JWT Tokens**
|
||||||
|
- Issued by the gateway with configurable expiration
|
||||||
|
- JWKS endpoints available at `/v1/auth/jwks` and `/.well-known/jwks.json`
|
||||||
|
- Automatic refresh capability
|
||||||
|
|
||||||
|
**API Keys**
|
||||||
|
- Support for pre-configured API keys via `Authorization: Bearer <key>` or `X-API-Key` headers
|
||||||
|
- Optional namespace mapping for multi-tenant applications
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
#### Health & Status
|
||||||
|
```http
|
||||||
|
GET /health # Basic health check
|
||||||
|
GET /v1/health # Detailed health status
|
||||||
|
GET /v1/status # Network status
|
||||||
|
GET /v1/version # Version information
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Authentication (Public Endpoints)
|
||||||
|
```http
|
||||||
|
POST /v1/auth/challenge # Generate wallet challenge
|
||||||
|
POST /v1/auth/verify # Verify wallet signature
|
||||||
|
POST /v1/auth/register # Register new wallet
|
||||||
|
POST /v1/auth/refresh # Refresh JWT token
|
||||||
|
POST /v1/auth/logout # Clear authentication
|
||||||
|
GET /v1/auth/whoami # Current auth status
|
||||||
|
POST /v1/auth/api-key # Generate API key (authenticated)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Storage Operations
|
||||||
|
```http
|
||||||
|
POST /v1/storage/get # Retrieve data
|
||||||
|
POST /v1/storage/put # Store data
|
||||||
|
POST /v1/storage/delete # Delete data
|
||||||
|
GET /v1/storage/list # List keys with optional prefix
|
||||||
|
GET /v1/storage/exists # Check key existence
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Network Operations
|
||||||
|
```http
|
||||||
|
GET /v1/network/status # Network status
|
||||||
|
GET /v1/network/peers # Connected peers
|
||||||
|
POST /v1/network/connect # Connect to peer
|
||||||
|
POST /v1/network/disconnect # Disconnect from peer
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pub/Sub Messaging
|
||||||
|
|
||||||
|
**WebSocket Interface**
|
||||||
|
```http
|
||||||
|
GET /v1/pubsub/ws?topic=<topic> # WebSocket connection for real-time messaging
|
||||||
|
```
|
||||||
|
|
||||||
|
**REST Interface**
|
||||||
|
```http
|
||||||
|
POST /v1/pubsub/publish # Publish message to topic
|
||||||
|
GET /v1/pubsub/topics # List active topics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
|
||||||
|
- **Namespace Enforcement:** All operations are automatically prefixed with namespace for isolation
|
||||||
|
- **CORS Support:** Configurable CORS policies (permissive for development, configurable for production)
|
||||||
|
- **Transport Security:** All network communications use Noise/TLS encryption
|
||||||
|
- **Authentication Middleware:** Flexible authentication with support for multiple credential types
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
#### Wallet Authentication Flow
|
||||||
|
```bash
|
||||||
|
# 1. Get challenge (automatic)
|
||||||
|
curl -X POST http://localhost:8080/v1/auth/challenge
|
||||||
|
|
||||||
|
# 2. Sign challenge with wallet (handled by client)
|
||||||
|
# 3. Verify signature (automatic)
|
||||||
|
curl -X POST http://localhost:8080/v1/auth/verify \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"wallet":"0x...","nonce":"...","signature":"0x..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Storage Operations
|
||||||
|
```bash
|
||||||
|
# Store data
|
||||||
|
curl -X POST http://localhost:8080/v1/storage/put \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"key":"user:123","value":"eyJuYW1lIjoiSm9obiJ9"}'
|
||||||
|
|
||||||
|
# Retrieve data
|
||||||
|
curl -X POST http://localhost:8080/v1/storage/get \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"key":"user:123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Real-time Messaging
|
||||||
|
```javascript
|
||||||
|
// WebSocket connection
|
||||||
|
const ws = new WebSocket('ws://localhost:8080/v1/pubsub/ws?topic=chat');
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
console.log('Received:', event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
ws.send('Hello, network!');
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
</text>
|
||||||
|
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
@ -380,6 +563,34 @@ scripts/test-multinode.sh
|
|||||||
- **Symptoms:** Memory usage grows continuously
|
- **Symptoms:** Memory usage grows continuously
|
||||||
- **Solutions:** Unsubscribe when done, monitor connection pool, review message retention.
|
- **Solutions:** Unsubscribe when done, monitor connection pool, review message retention.
|
||||||
|
|
||||||
|
#### Authentication Issues
|
||||||
|
|
||||||
|
- **Symptoms:** `Authentication failed`, `Invalid wallet signature`, `JWT token expired`
|
||||||
|
- **Solutions:**
|
||||||
|
- Check wallet signature format (65-byte r||s||v hex)
|
||||||
|
- Ensure nonce matches exactly during wallet verification
|
||||||
|
- Verify wallet address case-insensitivity
|
||||||
|
- Use refresh endpoint or re-authenticate for expired tokens
|
||||||
|
- Clear credential cache if multi-wallet conflicts occur: `rm -rf ~/.debros/credentials`
|
||||||
|
|
||||||
|
#### Gateway Issues
|
||||||
|
|
||||||
|
- **Symptoms:** `Gateway connection refused`, `CORS errors`, `WebSocket disconnections`
|
||||||
|
- **Solutions:**
|
||||||
|
- Verify gateway is running and accessible on configured port
|
||||||
|
- Check CORS configuration for web applications
|
||||||
|
- Ensure proper authentication headers for protected endpoints
|
||||||
|
- Verify namespace configuration and enforcement
|
||||||
|
|
||||||
|
#### Database Migration Issues
|
||||||
|
|
||||||
|
- **Symptoms:** `Migration failed`, `SQL syntax error`, `Version conflict`
|
||||||
|
- **Solutions:**
|
||||||
|
- Check SQL syntax in migration files
|
||||||
|
- Ensure proper statement termination
|
||||||
|
- Verify migration file naming and sequential order
|
||||||
|
- Review migration logs for transaction rollbacks
|
||||||
|
|
||||||
### Debugging & Health Checks
|
### Debugging & Health Checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -389,12 +600,27 @@ export LOG_LEVEL=debug
|
|||||||
./bin/network-cli query "SELECT 1"
|
./bin/network-cli query "SELECT 1"
|
||||||
./bin/network-cli pubsub publish test "hello"
|
./bin/network-cli pubsub publish test "hello"
|
||||||
./bin/network-cli pubsub subscribe test 10s
|
./bin/network-cli pubsub subscribe test 10s
|
||||||
|
|
||||||
|
# Test authentication flow
|
||||||
|
./bin/network-cli storage put test-key test-value
|
||||||
|
|
||||||
|
# Gateway health checks
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
curl http://localhost:8080/v1/status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Logs
|
### Service Logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Node service logs
|
||||||
sudo journalctl -u debros-node.service --since "1 hour ago"
|
sudo journalctl -u debros-node.service --since "1 hour ago"
|
||||||
|
|
||||||
|
# Gateway service logs (if running as service)
|
||||||
|
sudo journalctl -u debros-gateway.service --since "1 hour ago"
|
||||||
|
|
||||||
|
# Application logs
|
||||||
|
tail -f ./logs/gateway.log
|
||||||
|
tail -f ./logs/node.log
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
135
TASK.md
Normal file
135
TASK.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# Task: Enforce API Key/JWT and Namespace in Go Client (Auto-Resolve Namespace) and Guard All Operations
|
||||||
|
|
||||||
|
Owner: To be assigned
|
||||||
|
Status: Ready to implement
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Implement strict client-side access enforcement in the Go client (`pkg/client`) so that:
|
||||||
|
- An API key or JWT is required by default to use the client.
|
||||||
|
- The client auto-resolves the namespace from the provided API key or JWT without requiring callers to pass the namespace per call.
|
||||||
|
- Per-call namespace overrides via context are still allowed for compatibility, but must match the resolved namespace; otherwise, deny the call.
|
||||||
|
- All operations (Storage, PubSub, Database/RQLite, and NetworkInfo) are guarded and return access errors when unauthenticated or namespace-mismatched.
|
||||||
|
- No backward compatibility guarantees required.
|
||||||
|
|
||||||
|
Note: This is client-side enforcement for now. Protocol-level auth/ACL for libp2p can be added later.
|
||||||
|
|
||||||
|
## High-level behavior
|
||||||
|
- `ClientConfig.RequireAPIKey` defaults to true. If true and neither `APIKey` nor `JWT` is present, `Connect()` fails.
|
||||||
|
- Namespace is automatically derived:
|
||||||
|
- From JWT: parse claims and read `Namespace` claim (no network roundtrip). Verification of signature is not required for this task; parsing is enough to derive namespace. Optionally, add a TODO hook for future verification against JWKS if provided.
|
||||||
|
- From API key: the namespace must be embedded in the key using a documented format (below). The client parses it locally and derives the namespace without any remote calls.
|
||||||
|
- All calls check that any provided per-call namespace override matches the derived namespace, else return an “access denied: namespace mismatch” error.
|
||||||
|
- All modules are guarded: Database (RQLite), Storage, PubSub, and NetworkInfo.
|
||||||
|
|
||||||
|
## API key and JWT formats
|
||||||
|
- JWT: RS256 token with claim `Namespace` (string). We will parse claims (unverified) to obtain `Namespace`.
|
||||||
|
- API key: change to an encoded format that includes the namespace so the client can parse locally. Options (pick one and implement consistently):
|
||||||
|
- Option A (dotted): `ak_<random>.<namespace>`
|
||||||
|
- Option B (colon): `ak_<random>:<namespace>`
|
||||||
|
- Option C (base64 JSON): base64url of `{ "kid": "...", "ns": "<namespace>" }` prefixed by `ak_`
|
||||||
|
|
||||||
|
For simplicity and readability, choose Option B: `ak_<random>:<namespace>`.
|
||||||
|
- Parsing rules:
|
||||||
|
- If `APIKey` contains a single colon, split and use the right side as `namespace` (trim spaces). If empty -> error.
|
||||||
|
- If more than one colon or invalid format -> error.
|
||||||
|
|
||||||
|
## Changes to implement
|
||||||
|
|
||||||
|
### 1) Client configuration and types
|
||||||
|
- File: `pkg/client/interface.go`
|
||||||
|
- Extend `ClientConfig`:
|
||||||
|
- `Namespace string` // optional; if empty, auto-derived from API key or JWT; if still empty, fallback to `AppName`.
|
||||||
|
- `RequireAPIKey bool` // default true; when true, require either `APIKey` or `JWT`.
|
||||||
|
- `JWT string` // optional bearer token; used for namespace derivation and future protocol auth.
|
||||||
|
- Update `DefaultClientConfig(appName string)` to set:
|
||||||
|
- `RequireAPIKey: true`
|
||||||
|
- `Namespace: ""` (meaning auto)
|
||||||
|
|
||||||
|
### 2) Namespace resolution and access gating
|
||||||
|
- File: `pkg/client/client.go`
|
||||||
|
- At construction or `Connect()` time:
|
||||||
|
- Implement `deriveNamespace()`:
|
||||||
|
- If `config.Namespace != ""`, use it.
|
||||||
|
- Else if `config.JWT != ""`, parse JWT claims (unverified) and read `Namespace` claim.
|
||||||
|
- Else if `config.APIKey != ""`, parse `ak_<random>:<namespace>` and extract namespace.
|
||||||
|
- Else use `config.AppName`.
|
||||||
|
- Store the resolved namespace back into `config.Namespace`.
|
||||||
|
- Enforce presence of credentials:
|
||||||
|
- If `config.RequireAPIKey` is true AND both `config.APIKey` and `config.JWT` are empty -> return error `access denied: API key or JWT required`.
|
||||||
|
- Add `func (c *Client) requireAccess(ctx context.Context) error` that:
|
||||||
|
- If `RequireAPIKey` and both `APIKey` and `JWT` are empty -> error `access denied: credentials required`.
|
||||||
|
- Resolve per-call namespace override from context (via storage/pubsub helpers below). If present and `override != c.config.Namespace` -> error `access denied: namespace mismatch`.
|
||||||
|
|
||||||
|
### 3) Guard all operations
|
||||||
|
- File: `pkg/client/implementations.go`
|
||||||
|
- At the start of each public method, call `client.requireAccess(ctx)` and return the error if any.
|
||||||
|
- DatabaseClientImpl: `Query`, `Transaction`, `CreateTable`, `DropTable`, `GetSchema`.
|
||||||
|
- StorageClientImpl: `Get`, `Put`, `Delete`, `List`, `Exists`.
|
||||||
|
- NetworkInfoImpl: `GetPeers`, `GetStatus`, `ConnectToPeer`, `DisconnectFromPeer`.
|
||||||
|
- For Storage operations, ensure we propagate the effective namespace:
|
||||||
|
- If override present and equals `config.Namespace`, pass that context through; else use `storage.WithNamespace(ctx, config.Namespace)`.
|
||||||
|
|
||||||
|
### 4) PubSub context-based namespace override (parity with Storage)
|
||||||
|
- Files: `pkg/pubsub/*`
|
||||||
|
- Add:
|
||||||
|
- `type ctxKey string`
|
||||||
|
- `const CtxKeyNamespaceOverride ctxKey = "pubsub_ns_override"`
|
||||||
|
- `func WithNamespace(ctx context.Context, ns string) context.Context`
|
||||||
|
- Update topic naming in `manager.go` and `subscriptions.go`/`publish.go`:
|
||||||
|
- Before computing `namespacedTopic`, check for ctx override; if present and non-empty, use it; else fall back to `m.namespace`.
|
||||||
|
|
||||||
|
### 5) Client context helper
|
||||||
|
- New file: `pkg/client/context.go`
|
||||||
|
- Add `func WithNamespace(ctx context.Context, ns string) context.Context` that applies both storage and pubsub overrides by chaining:
|
||||||
|
- `ctx = storage.WithNamespace(ctx, ns)`
|
||||||
|
- `ctx = pubsub.WithNamespace(ctx, ns)`
|
||||||
|
- return `ctx`
|
||||||
|
|
||||||
|
### 6) Documentation updates
|
||||||
|
- Files: `README.md`, `AI_CONTEXT.md`
|
||||||
|
- Document the new client auth behavior:
|
||||||
|
- An API key or JWT is required by default (`RequireAPIKey=true`).
|
||||||
|
- Namespace auto-derived from token:
|
||||||
|
- JWT claim `Namespace`.
|
||||||
|
- API key format `ak_<random>:<namespace>`.
|
||||||
|
- Per-call override via `client.WithNamespace(ctx, ns)` allowed but must match derived namespace.
|
||||||
|
- All modules (Storage, PubSub, Database, NetworkInfo) are guarded.
|
||||||
|
- Provide usage examples for constructing `ClientConfig` with API key or JWT and making calls.
|
||||||
|
|
||||||
|
## Helper details
|
||||||
|
- JWT parsing: implement a minimal helper to split the token and base64url-decode the payload; read `Namespace` field from JSON. Do not verify signature for this task. If parsing fails, return a clear error.
|
||||||
|
- API key parsing: simple split on `:`; trim spaces; validate non-empty.
|
||||||
|
|
||||||
|
## Error messages (standardize)
|
||||||
|
- Missing credentials: `access denied: API key or JWT required`
|
||||||
|
- Namespace mismatch: `access denied: namespace mismatch`
|
||||||
|
- Client not connected: keep existing `client not connected` error.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- Without credentials and `RequireAPIKey=true`, `Connect()` returns error and no operations are allowed.
|
||||||
|
- With API key `ak_abc123:myapp`, the client auto-resolves namespace `myapp`; operations succeed.
|
||||||
|
- With JWT containing `{ "Namespace": "myapp" }`, the client auto-resolves `myapp`; operations succeed.
|
||||||
|
- If a caller sets `client.WithNamespace(ctx, "otherNS")` while resolved namespace is `myapp`, any operation returns `access denied: namespace mismatch`.
|
||||||
|
- PubSub topic names use the override when present (and allowed) else the resolved namespace.
|
||||||
|
- NetworkInfo methods are also guarded and require credentials.
|
||||||
|
|
||||||
|
## Out of scope (for this task)
|
||||||
|
- Protocol-level auth or verification of JWT signatures against JWKS.
|
||||||
|
- ETH payments/subscriptions and tier enforcement. (Separate design/implementation.)
|
||||||
|
|
||||||
|
## Files to modify/add
|
||||||
|
- Modify:
|
||||||
|
- `pkg/client/interface.go`
|
||||||
|
- `pkg/client/client.go`
|
||||||
|
- `pkg/client/implementations.go`
|
||||||
|
- `pkg/pubsub/manager.go`
|
||||||
|
- `pkg/pubsub/subscriptions.go`
|
||||||
|
- `pkg/pubsub/publish.go` (if exists; add override resolution there too)
|
||||||
|
- `README.md`, `AI_CONTEXT.md`
|
||||||
|
- Add:
|
||||||
|
- `pkg/pubsub/context.go` (if not present)
|
||||||
|
- `pkg/client/context.go`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep logs concise and avoid leaking tokens in logs. You may log the resolved namespace at `INFO` level on connect.
|
||||||
|
- Ensure thread-safety when accessing `Client.config` fields (use existing locks if needed).
|
104
cmd/cli/main.go
104
cmd/cli/main.go
@ -5,12 +5,15 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.debros.io/DeBros/network/pkg/anyoneproxy"
|
"git.debros.io/DeBros/network/pkg/anyoneproxy"
|
||||||
|
"git.debros.io/DeBros/network/pkg/auth"
|
||||||
"git.debros.io/DeBros/network/pkg/client"
|
"git.debros.io/DeBros/network/pkg/client"
|
||||||
"github.com/libp2p/go-libp2p/core/crypto"
|
"github.com/libp2p/go-libp2p/core/crypto"
|
||||||
"github.com/libp2p/go-libp2p/core/peer"
|
"github.com/libp2p/go-libp2p/core/peer"
|
||||||
@ -83,6 +86,7 @@ func main() {
|
|||||||
handlePeerID()
|
handlePeerID()
|
||||||
case "help", "--help", "-h":
|
case "help", "--help", "-h":
|
||||||
showHelp()
|
showHelp()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
|
||||||
showHelp()
|
showHelp()
|
||||||
@ -185,6 +189,9 @@ func handleStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleQuery(sql string) {
|
func handleQuery(sql string) {
|
||||||
|
// Ensure user is authenticated
|
||||||
|
_ = ensureAuthenticated()
|
||||||
|
|
||||||
client, err := createClient()
|
client, err := createClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
||||||
@ -214,6 +221,9 @@ func handleStorage(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user is authenticated
|
||||||
|
_ = ensureAuthenticated()
|
||||||
|
|
||||||
client, err := createClient()
|
client, err := createClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
||||||
@ -283,6 +293,9 @@ func handlePubSub(args []string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user is authenticated
|
||||||
|
_ = ensureAuthenticated()
|
||||||
|
|
||||||
client, err := createClient()
|
client, err := createClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err)
|
||||||
@ -358,6 +371,44 @@ func handlePubSub(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureAuthenticated ensures the user has valid credentials for the gateway
|
||||||
|
// Returns the credentials or exits the program on failure
|
||||||
|
func ensureAuthenticated() *auth.Credentials {
|
||||||
|
gatewayURL := auth.GetDefaultGatewayURL()
|
||||||
|
|
||||||
|
credentials, err := auth.GetOrPromptForCredentials(gatewayURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Authentication failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser(target string) error {
|
||||||
|
cmds := [][]string{
|
||||||
|
{"xdg-open", target},
|
||||||
|
{"open", target},
|
||||||
|
{"cmd", "/c", "start", target},
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
cmd := exec.Command(c[0], c[1:]...)
|
||||||
|
if err := cmd.Start(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Please open %s manually", target)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getenvDefault returns env var or default if empty/undefined.
|
||||||
|
func getenvDefault(key, def string) string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
func handleConnect(peerAddr string) {
|
func handleConnect(peerAddr string) {
|
||||||
client, err := createClient()
|
client, err := createClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -447,6 +498,39 @@ func handlePeerID() {
|
|||||||
func createClient() (client.NetworkClient, error) {
|
func createClient() (client.NetworkClient, error) {
|
||||||
config := client.DefaultClientConfig("network-cli")
|
config := client.DefaultClientConfig("network-cli")
|
||||||
|
|
||||||
|
// Check for existing credentials
|
||||||
|
creds, err := auth.GetValidCredentials()
|
||||||
|
if err != nil {
|
||||||
|
// No valid credentials found, trigger authentication flow
|
||||||
|
fmt.Printf("🔐 Authentication required for DeBros Network CLI\n")
|
||||||
|
fmt.Printf("💡 This will open your browser to authenticate with your wallet\n")
|
||||||
|
|
||||||
|
gatewayURL := auth.GetDefaultGatewayURL()
|
||||||
|
fmt.Printf("🌐 Gateway: %s\n\n", gatewayURL)
|
||||||
|
|
||||||
|
// Perform wallet authentication
|
||||||
|
newCreds, authErr := auth.PerformWalletAuthentication(gatewayURL)
|
||||||
|
if authErr != nil {
|
||||||
|
return nil, fmt.Errorf("authentication failed: %w", authErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials
|
||||||
|
if saveErr := auth.SaveCredentialsForDefaultGateway(newCreds); saveErr != nil {
|
||||||
|
fmt.Printf("⚠️ Warning: failed to save credentials: %v\n", saveErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("💾 Credentials saved to ~/.debros/credentials.json\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
creds = newCreds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure client with API key
|
||||||
|
config.APIKey = creds.APIKey
|
||||||
|
|
||||||
|
// Update last used time
|
||||||
|
creds.UpdateLastUsed()
|
||||||
|
auth.SaveCredentialsForDefaultGateway(creds) // Best effort save
|
||||||
|
|
||||||
networkClient, err := client.NewClient(config)
|
networkClient, err := client.NewClient(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -513,25 +597,31 @@ func isPrintableText(s string) bool {
|
|||||||
func showHelp() {
|
func showHelp() {
|
||||||
fmt.Printf("Network CLI - Distributed P2P Network Management Tool\n\n")
|
fmt.Printf("Network CLI - Distributed P2P Network Management Tool\n\n")
|
||||||
fmt.Printf("Usage: network-cli <command> [args...]\n\n")
|
fmt.Printf("Usage: network-cli <command> [args...]\n\n")
|
||||||
|
fmt.Printf("🔐 Authentication: Commands requiring authentication will automatically prompt for wallet connection.\n\n")
|
||||||
fmt.Printf("Commands:\n")
|
fmt.Printf("Commands:\n")
|
||||||
fmt.Printf(" health - Check network health\n")
|
fmt.Printf(" health - Check network health\n")
|
||||||
fmt.Printf(" peers - List connected peers\n")
|
fmt.Printf(" peers - List connected peers\n")
|
||||||
fmt.Printf(" status - Show network status\n")
|
fmt.Printf(" status - Show network status\n")
|
||||||
fmt.Printf(" peer-id - Show this node's peer ID\n")
|
fmt.Printf(" peer-id - Show this node's peer ID\n")
|
||||||
fmt.Printf(" query <sql> - Execute database query\n")
|
fmt.Printf(" query <sql> 🔐 Execute database query\n")
|
||||||
fmt.Printf(" storage get <key> - Get value from storage\n")
|
fmt.Printf(" storage get <key> 🔐 Get value from storage\n")
|
||||||
fmt.Printf(" storage put <key> <value> - Store value in storage\n")
|
fmt.Printf(" storage put <key> <value> 🔐 Store value in storage\n")
|
||||||
fmt.Printf(" storage list [prefix] - List storage keys\n")
|
fmt.Printf(" storage list [prefix] 🔐 List storage keys\n")
|
||||||
fmt.Printf(" pubsub publish <topic> <msg> - Publish message\n")
|
fmt.Printf(" pubsub publish <topic> <msg> 🔐 Publish message\n")
|
||||||
fmt.Printf(" pubsub subscribe <topic> [duration] - Subscribe to topic\n")
|
fmt.Printf(" pubsub subscribe <topic> [duration] 🔐 Subscribe to topic\n")
|
||||||
fmt.Printf(" pubsub topics - List topics\n")
|
fmt.Printf(" pubsub topics 🔐 List topics\n")
|
||||||
fmt.Printf(" connect <peer_address> - Connect to peer\n")
|
fmt.Printf(" connect <peer_address> - Connect to peer\n")
|
||||||
|
|
||||||
fmt.Printf(" help - Show this help\n\n")
|
fmt.Printf(" help - Show this help\n\n")
|
||||||
fmt.Printf("Global Flags:\n")
|
fmt.Printf("Global Flags:\n")
|
||||||
fmt.Printf(" -b, --bootstrap <addr> - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n")
|
fmt.Printf(" -b, --bootstrap <addr> - Bootstrap peer address (default: /ip4/127.0.0.1/tcp/4001)\n")
|
||||||
fmt.Printf(" -f, --format <format> - Output format: table, json (default: table)\n")
|
fmt.Printf(" -f, --format <format> - Output format: table, json (default: table)\n")
|
||||||
fmt.Printf(" -t, --timeout <duration> - Operation timeout (default: 30s)\n")
|
fmt.Printf(" -t, --timeout <duration> - Operation timeout (default: 30s)\n")
|
||||||
fmt.Printf(" --production - Connect to production bootstrap peers\n\n")
|
fmt.Printf(" --production - Connect to production bootstrap peers\n\n")
|
||||||
|
fmt.Printf("Authentication:\n")
|
||||||
|
fmt.Printf(" Commands marked with 🔐 will automatically prompt for wallet authentication\n")
|
||||||
|
fmt.Printf(" if no valid credentials are found. You can manage multiple wallets and\n")
|
||||||
|
fmt.Printf(" choose between them during the authentication flow.\n\n")
|
||||||
fmt.Printf("Examples:\n")
|
fmt.Printf("Examples:\n")
|
||||||
fmt.Printf(" network-cli health\n")
|
fmt.Printf(" network-cli health\n")
|
||||||
fmt.Printf(" network-cli peer-id\n")
|
fmt.Printf(" network-cli peer-id\n")
|
||||||
|
71
cmd/gateway/config.go
Normal file
71
cmd/gateway/config.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/gateway"
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// For transition, alias main.GatewayConfig to pkg/gateway.Config
|
||||||
|
// server.go will be removed; this keeps compatibility until then.
|
||||||
|
type GatewayConfig = gateway.Config
|
||||||
|
|
||||||
|
func getEnvDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvBoolDefault(key string, def bool) bool {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "1", "true", "t", "yes", "y", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "f", "no", "n", "off":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGatewayConfig parses flags and environment variables into GatewayConfig.
|
||||||
|
// Priority: flags > env > defaults.
|
||||||
|
func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
||||||
|
addr := flag.String("addr", getEnvDefault("GATEWAY_ADDR", ":8080"), "HTTP listen address (e.g., :8080)")
|
||||||
|
ns := flag.String("namespace", getEnvDefault("GATEWAY_NAMESPACE", "default"), "Client namespace for scoping resources")
|
||||||
|
peers := flag.String("bootstrap-peers", getEnvDefault("GATEWAY_BOOTSTRAP_PEERS", ""), "Comma-separated bootstrap peers for network client")
|
||||||
|
|
||||||
|
// Do not call flag.Parse() elsewhere to avoid double-parsing
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
var bootstrap []string
|
||||||
|
if p := strings.TrimSpace(*peers); p != "" {
|
||||||
|
parts := strings.Split(p, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
val := strings.TrimSpace(part)
|
||||||
|
if val != "" {
|
||||||
|
bootstrap = append(bootstrap, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Loaded gateway configuration",
|
||||||
|
zap.String("addr", *addr),
|
||||||
|
zap.String("namespace", *ns),
|
||||||
|
zap.Int("bootstrap_peer_count", len(bootstrap)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &gateway.Config{
|
||||||
|
ListenAddr: *addr,
|
||||||
|
ClientNamespace: *ns,
|
||||||
|
BootstrapPeers: bootstrap,
|
||||||
|
}
|
||||||
|
}
|
102
cmd/gateway/main.go
Normal file
102
cmd/gateway/main.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/gateway"
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupLogger() *logging.ColoredLogger {
|
||||||
|
logger, err := logging.NewColoredLogger(logging.ComponentGeneral, true)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
logger := setupLogger()
|
||||||
|
|
||||||
|
// Load gateway config (flags/env)
|
||||||
|
cfg := parseGatewayConfig(logger)
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Starting gateway initialization...")
|
||||||
|
|
||||||
|
// Initialize gateway (connect client, prepare routes)
|
||||||
|
gw, err := gateway.New(logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentGeneral, "failed to initialize gateway", zap.Error(err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer gw.Close()
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Gateway initialization completed successfully")
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Creating HTTP server and routes...")
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: cfg.ListenAddr,
|
||||||
|
Handler: gw.Routes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to bind listener explicitly so binding failures are visible immediately.
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Gateway HTTP server starting",
|
||||||
|
zap.String("addr", cfg.ListenAddr),
|
||||||
|
zap.String("namespace", cfg.ClientNamespace),
|
||||||
|
zap.Int("bootstrap_peer_count", len(cfg.BootstrapPeers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Attempting to bind HTTP listener...")
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", cfg.ListenAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentGeneral, "failed to bind HTTP listen address", zap.Error(err))
|
||||||
|
// exit because server cannot function without a listener
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "HTTP listener bound", zap.String("listen_addr", ln.Addr().String()))
|
||||||
|
|
||||||
|
// Serve in a goroutine so we can handle graceful shutdown on signals.
|
||||||
|
serveErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
|
serveErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveErrCh <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for termination signal or server error
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sig := <-quit:
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "shutdown signal received", zap.String("signal", sig.String()))
|
||||||
|
case err := <-serveErrCh:
|
||||||
|
if err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentGeneral, "HTTP server error", zap.Error(err))
|
||||||
|
// continue to shutdown path so we close resources cleanly
|
||||||
|
} else {
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "HTTP server exited normally")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Shutting down gateway HTTP server...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentGeneral, "HTTP server shutdown error", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Gateway shutdown complete")
|
||||||
|
}
|
||||||
|
}
|
5
go.mod
5
go.mod
@ -5,6 +5,8 @@ go 1.23.8
|
|||||||
toolchain go1.24.1
|
toolchain go1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ethereum/go-ethereum v1.13.14
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/libp2p/go-libp2p v0.41.1
|
github.com/libp2p/go-libp2p v0.41.1
|
||||||
github.com/libp2p/go-libp2p-pubsub v0.14.2
|
github.com/libp2p/go-libp2p-pubsub v0.14.2
|
||||||
github.com/multiformats/go-multiaddr v0.15.0
|
github.com/multiformats/go-multiaddr v0.15.0
|
||||||
@ -17,6 +19,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/containerd/cgroups v1.1.0 // indirect
|
github.com/containerd/cgroups v1.1.0 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
@ -32,8 +35,8 @@ require (
|
|||||||
github.com/google/gopacket v1.1.19 // indirect
|
github.com/google/gopacket v1.1.19 // indirect
|
||||||
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
|
github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
|
github.com/holiman/uint256 v1.2.4 // indirect
|
||||||
github.com/huin/goupnp v1.3.0 // indirect
|
github.com/huin/goupnp v1.3.0 // indirect
|
||||||
github.com/ipfs/go-cid v0.5.0 // indirect
|
github.com/ipfs/go-cid v0.5.0 // indirect
|
||||||
github.com/ipfs/go-log/v2 v2.6.0 // indirect
|
github.com/ipfs/go-log/v2 v2.6.0 // indirect
|
||||||
|
8
go.sum
8
go.sum
@ -16,6 +16,10 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
|
|||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k=
|
||||||
|
github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU=
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U=
|
||||||
|
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
|
||||||
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
@ -46,6 +50,8 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn
|
|||||||
github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||||
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
||||||
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||||
|
github.com/ethereum/go-ethereum v1.13.14 h1:EwiY3FZP94derMCIam1iW4HFVrSgIcpsu0HwTQtm6CQ=
|
||||||
|
github.com/ethereum/go-ethereum v1.13.14/go.mod h1:TN8ZiHrdJwSe8Cb6x+p0hs5CxhJZPbqB7hHkaUXcmIU=
|
||||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||||
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
|
||||||
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||||
@ -97,6 +103,8 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:Fecb
|
|||||||
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU=
|
||||||
|
github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||||
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
|
||||||
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
|
||||||
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
|
github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
|
||||||
|
55
migrations/001_initial.sql
Normal file
55
migrations/001_initial.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
-- DeBros Gateway - Initial database schema (SQLite/RQLite dialect)
|
||||||
|
-- This file scaffolds core tables used by the HTTP gateway for auth, observability, and namespacing.
|
||||||
|
-- Apply via your migration tooling or manual execution in RQLite.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Tracks applied migrations (optional if your runner manages this separately)
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Namespaces (tenant/app isolation)
|
||||||
|
CREATE TABLE IF NOT EXISTS namespaces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- API keys (basic authentication/authorization scaffold)
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
scopes TEXT, -- comma-separated or JSON array; refine later
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_namespace ON api_keys(namespace_id);
|
||||||
|
|
||||||
|
-- Request logs (simple observability; expand with more fields later)
|
||||||
|
CREATE TABLE IF NOT EXISTS request_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
status_code INTEGER NOT NULL,
|
||||||
|
bytes_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ip TEXT,
|
||||||
|
api_key_id INTEGER,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_api_key ON request_logs(api_key_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
||||||
|
|
||||||
|
-- Seed a default namespace for development convenience
|
||||||
|
INSERT OR IGNORE INTO namespaces(name) VALUES ('default');
|
||||||
|
|
||||||
|
-- Mark this migration as applied (optional)
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version) VALUES (1);
|
||||||
|
|
||||||
|
COMMIT;
|
95
migrations/002_core.sql
Normal file
95
migrations/002_core.sql
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
-- DeBros Gateway - Core schema (Phase 2)
|
||||||
|
-- Adds apps, nonces, subscriptions, refresh_tokens, audit_events, namespace_ownership
|
||||||
|
-- SQLite/RQLite dialect
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Apps registered within a namespace (optional public key for attestation)
|
||||||
|
CREATE TABLE IF NOT EXISTS apps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
app_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
public_key TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(namespace_id, app_id),
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_apps_namespace ON apps(namespace_id);
|
||||||
|
|
||||||
|
-- Wallet nonces for challenge-response auth
|
||||||
|
CREATE TABLE IF NOT EXISTS nonces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
purpose TEXT,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
used_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(namespace_id, wallet, nonce),
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nonces_wallet ON nonces(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nonces_expires ON nonces(expires_at);
|
||||||
|
|
||||||
|
-- Subscriptions to topics or channels for callbacks/notifications
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
app_id INTEGER,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
endpoint TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(app_id) REFERENCES apps(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_ns ON subscriptions(namespace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_topic ON subscriptions(topic);
|
||||||
|
|
||||||
|
-- Opaque refresh tokens for JWT
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
audience TEXT,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_subject ON refresh_tokens(subject);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_expires ON refresh_tokens(expires_at);
|
||||||
|
|
||||||
|
-- Audit events for security and observability
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
actor TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_ns_time ON audit_events(namespace_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||||
|
|
||||||
|
-- Namespace ownership mapping (who controls a namespace)
|
||||||
|
CREATE TABLE IF NOT EXISTS namespace_ownership (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
owner_type TEXT NOT NULL, -- e.g., 'wallet', 'api_key'
|
||||||
|
owner_id TEXT NOT NULL, -- e.g., wallet address or api key string
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(namespace_id, owner_type, owner_id),
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ns_owner_ns ON namespace_ownership(namespace_id);
|
||||||
|
|
||||||
|
-- Optional marker (ignored by runner)
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version) VALUES (2);
|
||||||
|
|
||||||
|
COMMIT;
|
21
migrations/003_wallet_api_keys.sql
Normal file
21
migrations/003_wallet_api_keys.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- DeBros Gateway - Wallet to API Key linkage (Phase 3)
|
||||||
|
-- Ensures one API key per (namespace, wallet) and enables lookup
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
namespace_id INTEGER NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
api_key_id INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(namespace_id, wallet),
|
||||||
|
FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_api_keys_ns ON wallet_api_keys(namespace_id);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations(version) VALUES (3);
|
||||||
|
|
||||||
|
COMMIT;
|
234
pkg/auth/credentials.go
Normal file
234
pkg/auth/credentials.go
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials represents authentication credentials for a specific gateway
|
||||||
|
type Credentials struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
Wallet string `json:"wallet,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
LastUsedAt time.Time `json:"last_used_at,omitempty"`
|
||||||
|
Plan string `json:"plan,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CredentialStore manages credentials for multiple gateways
|
||||||
|
type CredentialStore struct {
|
||||||
|
Gateways map[string]*Credentials `json:"gateways"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentialsPath returns the path to the credentials file
|
||||||
|
func GetCredentialsPath() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
debrosDir := filepath.Join(homeDir, ".debros")
|
||||||
|
if err := os.MkdirAll(debrosDir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create .debros directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(debrosDir, "credentials.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCredentials loads credentials from ~/.debros/credentials.json
|
||||||
|
func LoadCredentials() (*CredentialStore, error) {
|
||||||
|
credPath, err := GetCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file doesn't exist, return empty store
|
||||||
|
if _, err := os.Stat(credPath); os.IsNotExist(err) {
|
||||||
|
return &CredentialStore{
|
||||||
|
Gateways: make(map[string]*Credentials),
|
||||||
|
Version: "1.0",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(credPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var store CredentialStore
|
||||||
|
if err := json.Unmarshal(data, &store); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize gateways map if nil
|
||||||
|
if store.Gateways == nil {
|
||||||
|
store.Gateways = make(map[string]*Credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set version if empty
|
||||||
|
if store.Version == "" {
|
||||||
|
store.Version = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCredentials saves credentials to ~/.debros/credentials.json
|
||||||
|
func (store *CredentialStore) SaveCredentials() error {
|
||||||
|
credPath, err := GetCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure version is set
|
||||||
|
if store.Version == "" {
|
||||||
|
store.Version = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write with restricted permissions (readable only by owner)
|
||||||
|
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentialsForGateway returns credentials for a specific gateway URL
|
||||||
|
func (store *CredentialStore) GetCredentialsForGateway(gatewayURL string) (*Credentials, bool) {
|
||||||
|
creds, exists := store.Gateways[gatewayURL]
|
||||||
|
if !exists || creds == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if credentials are expired (if expiration is set)
|
||||||
|
if !creds.ExpiresAt.IsZero() && time.Now().After(creds.ExpiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCredentialsForGateway stores credentials for a specific gateway URL
|
||||||
|
func (store *CredentialStore) SetCredentialsForGateway(gatewayURL string, creds *Credentials) {
|
||||||
|
if store.Gateways == nil {
|
||||||
|
store.Gateways = make(map[string]*Credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used time
|
||||||
|
creds.LastUsedAt = time.Now()
|
||||||
|
|
||||||
|
store.Gateways[gatewayURL] = creds
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveCredentialsForGateway removes credentials for a specific gateway URL
|
||||||
|
func (store *CredentialStore) RemoveCredentialsForGateway(gatewayURL string) {
|
||||||
|
if store.Gateways != nil {
|
||||||
|
delete(store.Gateways, gatewayURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired checks if credentials are expired
|
||||||
|
func (creds *Credentials) IsExpired() bool {
|
||||||
|
if creds.ExpiresAt.IsZero() {
|
||||||
|
return false // No expiration set
|
||||||
|
}
|
||||||
|
return time.Now().After(creds.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid checks if credentials are valid (not empty and not expired)
|
||||||
|
func (creds *Credentials) IsValid() bool {
|
||||||
|
if creds == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !creds.IsExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastUsed updates the last used timestamp
|
||||||
|
func (creds *Credentials) UpdateLastUsed() {
|
||||||
|
creds.LastUsedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultGatewayURL returns the default gateway URL from environment or fallback
|
||||||
|
func GetDefaultGatewayURL() string {
|
||||||
|
if envURL := os.Getenv("DEBROS_GATEWAY_URL"); envURL != "" {
|
||||||
|
return envURL
|
||||||
|
}
|
||||||
|
if envURL := os.Getenv("DEBROS_GATEWAY"); envURL != "" {
|
||||||
|
return envURL
|
||||||
|
}
|
||||||
|
return "http://localhost:8005"
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasValidCredentials checks if there are valid credentials for the default gateway
|
||||||
|
func HasValidCredentials() (bool, error) {
|
||||||
|
store, err := LoadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayURL := GetDefaultGatewayURL()
|
||||||
|
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||||
|
|
||||||
|
return exists && creds.IsValid(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidCredentials returns valid credentials for the default gateway
|
||||||
|
func GetValidCredentials() (*Credentials, error) {
|
||||||
|
store, err := LoadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayURL := GetDefaultGatewayURL()
|
||||||
|
creds, exists := store.GetCredentialsForGateway(gatewayURL)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("no credentials found for gateway %s", gatewayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !creds.IsValid() {
|
||||||
|
return nil, fmt.Errorf("credentials for gateway %s are expired or invalid", gatewayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCredentialsForDefaultGateway saves credentials for the default gateway
|
||||||
|
func SaveCredentialsForDefaultGateway(creds *Credentials) error {
|
||||||
|
store, err := LoadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayURL := GetDefaultGatewayURL()
|
||||||
|
store.SetCredentialsForGateway(gatewayURL, creds)
|
||||||
|
|
||||||
|
return store.SaveCredentials()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllCredentials removes all stored credentials
|
||||||
|
func ClearAllCredentials() error {
|
||||||
|
store := &CredentialStore{
|
||||||
|
Gateways: make(map[string]*Credentials),
|
||||||
|
Version: "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.SaveCredentials()
|
||||||
|
}
|
395
pkg/auth/enhanced_auth.go
Normal file
395
pkg/auth/enhanced_auth.go
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnhancedCredentialStore manages multiple credentials per gateway
|
||||||
|
type EnhancedCredentialStore struct {
|
||||||
|
Gateways map[string]*GatewayCredentials `json:"gateways"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewayCredentials holds multiple credentials for a single gateway
|
||||||
|
type GatewayCredentials struct {
|
||||||
|
Credentials []*Credentials `json:"credentials"`
|
||||||
|
DefaultIndex int `json:"default_index"`
|
||||||
|
LastUsedIndex int `json:"last_used_index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthChoice represents user's choice during authentication
|
||||||
|
type AuthChoice int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthChoiceUseCredential AuthChoice = iota
|
||||||
|
AuthChoiceAddCredential
|
||||||
|
AuthChoiceLogout
|
||||||
|
AuthChoiceExit
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadEnhancedCredentials loads the enhanced credential store
|
||||||
|
func LoadEnhancedCredentials() (*EnhancedCredentialStore, error) {
|
||||||
|
credPath, err := GetCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file doesn't exist, return empty store
|
||||||
|
if _, err := os.Stat(credPath); os.IsNotExist(err) {
|
||||||
|
return &EnhancedCredentialStore{
|
||||||
|
Gateways: make(map[string]*GatewayCredentials),
|
||||||
|
Version: "2.0",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(credPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as enhanced store first
|
||||||
|
var enhancedStore EnhancedCredentialStore
|
||||||
|
if err := json.Unmarshal(data, &enhancedStore); err == nil && enhancedStore.Version == "2.0" {
|
||||||
|
// Initialize maps if nil
|
||||||
|
if enhancedStore.Gateways == nil {
|
||||||
|
enhancedStore.Gateways = make(map[string]*GatewayCredentials)
|
||||||
|
}
|
||||||
|
return &enhancedStore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to old format and migrate
|
||||||
|
var oldStore CredentialStore
|
||||||
|
if err := json.Unmarshal(data, &oldStore); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate old format to new
|
||||||
|
enhancedStore = EnhancedCredentialStore{
|
||||||
|
Gateways: make(map[string]*GatewayCredentials),
|
||||||
|
Version: "2.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
for gatewayURL, creds := range oldStore.Gateways {
|
||||||
|
if creds != nil {
|
||||||
|
enhancedStore.Gateways[gatewayURL] = &GatewayCredentials{
|
||||||
|
Credentials: []*Credentials{creds},
|
||||||
|
DefaultIndex: 0,
|
||||||
|
LastUsedIndex: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &enhancedStore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save saves the enhanced credential store
|
||||||
|
func (store *EnhancedCredentialStore) Save() error {
|
||||||
|
credPath, err := GetCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.Version == "" {
|
||||||
|
store.Version = "2.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(credPath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCredential adds a new credential for the gateway
|
||||||
|
func (store *EnhancedCredentialStore) AddCredential(gatewayURL string, creds *Credentials) {
|
||||||
|
if store.Gateways == nil {
|
||||||
|
store.Gateways = make(map[string]*GatewayCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
if gatewayCredentials == nil {
|
||||||
|
gatewayCredentials = &GatewayCredentials{
|
||||||
|
Credentials: []*Credentials{},
|
||||||
|
DefaultIndex: 0,
|
||||||
|
LastUsedIndex: 0,
|
||||||
|
}
|
||||||
|
store.Gateways[gatewayURL] = gatewayCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if credential already exists (by wallet address)
|
||||||
|
for i, existing := range gatewayCredentials.Credentials {
|
||||||
|
if strings.EqualFold(existing.Wallet, creds.Wallet) {
|
||||||
|
// Update existing credential
|
||||||
|
gatewayCredentials.Credentials[i] = creds
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new credential
|
||||||
|
gatewayCredentials.Credentials = append(gatewayCredentials.Credentials, creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultCredential returns the default credential for a gateway
|
||||||
|
func (store *EnhancedCredentialStore) GetDefaultCredential(gatewayURL string) *Credentials {
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
if gatewayCredentials == nil || len(gatewayCredentials.Credentials) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure default index is valid
|
||||||
|
if gatewayCredentials.DefaultIndex < 0 || gatewayCredentials.DefaultIndex >= len(gatewayCredentials.Credentials) {
|
||||||
|
gatewayCredentials.DefaultIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return gatewayCredentials.Credentials[gatewayCredentials.DefaultIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultCredential sets the default credential by index
|
||||||
|
func (store *EnhancedCredentialStore) SetDefaultCredential(gatewayURL string, index int) bool {
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
if gatewayCredentials == nil || index < 0 || index >= len(gatewayCredentials.Credentials) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayCredentials.DefaultIndex = index
|
||||||
|
gatewayCredentials.LastUsedIndex = index
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllCredentials removes all credentials
|
||||||
|
func (store *EnhancedCredentialStore) ClearAllCredentials() {
|
||||||
|
store.Gateways = make(map[string]*GatewayCredentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayCredentialMenu shows the interactive credential selection menu
|
||||||
|
func (store *EnhancedCredentialStore) DisplayCredentialMenu(gatewayURL string) (AuthChoice, int, error) {
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
|
||||||
|
if gatewayCredentials == nil || len(gatewayCredentials.Credentials) == 0 {
|
||||||
|
fmt.Println("\n🔐 No credentials found. Choose an option:")
|
||||||
|
fmt.Println("1. Authenticate with new wallet")
|
||||||
|
fmt.Println("2. Exit")
|
||||||
|
fmt.Print("Choose (1-2): ")
|
||||||
|
|
||||||
|
choice, err := readUserChoice(2)
|
||||||
|
if err != nil {
|
||||||
|
return AuthChoiceExit, -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case 1:
|
||||||
|
return AuthChoiceAddCredential, -1, nil
|
||||||
|
case 2:
|
||||||
|
return AuthChoiceExit, -1, nil
|
||||||
|
default:
|
||||||
|
return AuthChoiceExit, -1, fmt.Errorf("invalid choice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n🔐 Multiple wallets available for %s:\n", gatewayURL)
|
||||||
|
|
||||||
|
// Display credentials
|
||||||
|
for i, creds := range gatewayCredentials.Credentials {
|
||||||
|
defaultMark := ""
|
||||||
|
if i == gatewayCredentials.DefaultIndex {
|
||||||
|
defaultMark = " (default)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format wallet address for display
|
||||||
|
displayAddr := creds.Wallet
|
||||||
|
if len(displayAddr) > 10 {
|
||||||
|
displayAddr = displayAddr[:6] + "..." + displayAddr[len(displayAddr)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEmoji := "✅"
|
||||||
|
if !creds.IsValid() {
|
||||||
|
statusEmoji = "❌"
|
||||||
|
}
|
||||||
|
|
||||||
|
planInfo := ""
|
||||||
|
if creds.Plan != "" {
|
||||||
|
planInfo = fmt.Sprintf(" (%s)", creds.Plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%d. %s %s%s%s\n", i+1, statusEmoji, displayAddr, planInfo, defaultMark)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%d. Add new wallet\n", len(gatewayCredentials.Credentials)+1)
|
||||||
|
fmt.Printf("%d. Logout (clear all credentials)\n", len(gatewayCredentials.Credentials)+2)
|
||||||
|
fmt.Printf("%d. Exit\n", len(gatewayCredentials.Credentials)+3)
|
||||||
|
|
||||||
|
maxChoice := len(gatewayCredentials.Credentials) + 3
|
||||||
|
fmt.Printf("Choose (1-%d): ", maxChoice)
|
||||||
|
|
||||||
|
choice, err := readUserChoice(maxChoice)
|
||||||
|
if err != nil {
|
||||||
|
return AuthChoiceExit, -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice <= len(gatewayCredentials.Credentials) {
|
||||||
|
// User selected a credential
|
||||||
|
return AuthChoiceUseCredential, choice - 1, nil
|
||||||
|
} else if choice == len(gatewayCredentials.Credentials)+1 {
|
||||||
|
// Add new credential
|
||||||
|
return AuthChoiceAddCredential, -1, nil
|
||||||
|
} else if choice == len(gatewayCredentials.Credentials)+2 {
|
||||||
|
// Logout
|
||||||
|
return AuthChoiceLogout, -1, nil
|
||||||
|
} else {
|
||||||
|
// Exit
|
||||||
|
return AuthChoiceExit, -1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUserChoice reads and validates user input
|
||||||
|
func readUserChoice(maxChoice int) (int, error) {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
input, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read input: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
choiceStr := strings.TrimSpace(input)
|
||||||
|
choice, err := strconv.Atoi(choiceStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid input: please enter a number")
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice < 1 || choice > maxChoice {
|
||||||
|
return 0, fmt.Errorf("invalid choice: please enter a number between 1 and %d", maxChoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
return choice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrPromptForCredentials handles the complete authentication flow
|
||||||
|
func GetOrPromptForCredentials(gatewayURL string) (*Credentials, error) {
|
||||||
|
store, err := LoadEnhancedCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load credential store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have a valid default credential
|
||||||
|
defaultCreds := store.GetDefaultCredential(gatewayURL)
|
||||||
|
if defaultCreds != nil && defaultCreds.IsValid() {
|
||||||
|
// Update last used time
|
||||||
|
defaultCreds.UpdateLastUsed()
|
||||||
|
if err := store.Save(); err != nil {
|
||||||
|
// Log warning but don't fail
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to update last used time: %v\n", err)
|
||||||
|
}
|
||||||
|
return defaultCreds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to prompt user for credential selection
|
||||||
|
for {
|
||||||
|
choice, credIndex, err := store.DisplayCredentialMenu(gatewayURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("menu selection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case AuthChoiceUseCredential:
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
if gatewayCredentials == nil || credIndex < 0 || credIndex >= len(gatewayCredentials.Credentials) {
|
||||||
|
fmt.Println("❌ Invalid credential selection")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCreds := gatewayCredentials.Credentials[credIndex]
|
||||||
|
if !selectedCreds.IsValid() {
|
||||||
|
fmt.Println("❌ Selected credentials are invalid or expired")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default and last used
|
||||||
|
store.SetDefaultCredential(gatewayURL, credIndex)
|
||||||
|
selectedCreds.UpdateLastUsed()
|
||||||
|
|
||||||
|
if err := store.Save(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to save credentials: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedCreds, nil
|
||||||
|
|
||||||
|
case AuthChoiceAddCredential:
|
||||||
|
fmt.Println("\n🌐 Opening browser for wallet authentication...")
|
||||||
|
newCreds, err := PerformWalletAuthentication(gatewayURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Authentication failed: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new credential
|
||||||
|
store.AddCredential(gatewayURL, newCreds)
|
||||||
|
|
||||||
|
// Set as default if it's the first credential
|
||||||
|
gatewayCredentials := store.Gateways[gatewayURL]
|
||||||
|
if gatewayCredentials != nil && len(gatewayCredentials.Credentials) == 1 {
|
||||||
|
store.SetDefaultCredential(gatewayURL, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Save(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to save new credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✅ Wallet %s added successfully\n", newCreds.Wallet)
|
||||||
|
return newCreds, nil
|
||||||
|
|
||||||
|
case AuthChoiceLogout:
|
||||||
|
store.ClearAllCredentials()
|
||||||
|
if err := store.Save(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to clear credentials: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✅ All credentials cleared")
|
||||||
|
continue
|
||||||
|
|
||||||
|
case AuthChoiceExit:
|
||||||
|
return nil, fmt.Errorf("authentication cancelled by user")
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Println("❌ Invalid choice")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasValidEnhancedCredentials checks if there are valid credentials for the default gateway
|
||||||
|
func HasValidEnhancedCredentials() (bool, error) {
|
||||||
|
store, err := LoadEnhancedCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayURL := GetDefaultGatewayURL()
|
||||||
|
defaultCreds := store.GetDefaultCredential(gatewayURL)
|
||||||
|
|
||||||
|
return defaultCreds != nil && defaultCreds.IsValid(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidEnhancedCredentials returns valid credentials for the default gateway
|
||||||
|
func GetValidEnhancedCredentials() (*Credentials, error) {
|
||||||
|
store, err := LoadEnhancedCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayURL := GetDefaultGatewayURL()
|
||||||
|
defaultCreds := store.GetDefaultCredential(gatewayURL)
|
||||||
|
|
||||||
|
if defaultCreds == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials found for gateway %s", gatewayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !defaultCreds.IsValid() {
|
||||||
|
return nil, fmt.Errorf("credentials for gateway %s are expired or invalid", gatewayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultCreds, nil
|
||||||
|
}
|
310
pkg/auth/wallet.go
Normal file
310
pkg/auth/wallet.go
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WalletAuthResult represents the result of wallet authentication
|
||||||
|
type WalletAuthResult struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Plan string `json:"plan,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthServer handles the local HTTP server for receiving auth callbacks
|
||||||
|
type AuthServer struct {
|
||||||
|
server *http.Server
|
||||||
|
listener net.Listener
|
||||||
|
result chan WalletAuthResult
|
||||||
|
err chan error
|
||||||
|
mu sync.Mutex
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerformWalletAuthentication starts the complete wallet authentication flow
|
||||||
|
func PerformWalletAuthentication(gatewayURL string) (*Credentials, error) {
|
||||||
|
fmt.Printf("🔐 Starting wallet authentication for gateway: %s\n", gatewayURL)
|
||||||
|
|
||||||
|
// Start local callback server
|
||||||
|
authServer, err := NewAuthServer()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start auth server: %w", err)
|
||||||
|
}
|
||||||
|
defer authServer.Close()
|
||||||
|
|
||||||
|
callbackURL := fmt.Sprintf("http://localhost:%d/callback", authServer.GetPort())
|
||||||
|
fmt.Printf("📡 Authentication server started on port %d\n", authServer.GetPort())
|
||||||
|
|
||||||
|
// Open browser to gateway auth page
|
||||||
|
authURL := fmt.Sprintf("%s/v1/auth/login?callback=%s", gatewayURL, url.QueryEscape(callbackURL))
|
||||||
|
fmt.Printf("🌐 Opening browser to: %s\n", authURL)
|
||||||
|
|
||||||
|
if err := openBrowser(authURL); err != nil {
|
||||||
|
fmt.Printf("⚠️ Failed to open browser automatically: %v\n", err)
|
||||||
|
fmt.Printf("📋 Please manually open this URL in your browser:\n%s\n", authURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("⏳ Waiting for authentication to complete...")
|
||||||
|
fmt.Println("💡 Complete the wallet signature in your browser, then return here.")
|
||||||
|
|
||||||
|
// Wait for authentication result with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-authServer.result:
|
||||||
|
fmt.Println("✅ Authentication successful!")
|
||||||
|
return convertAuthResult(result), nil
|
||||||
|
case err := <-authServer.err:
|
||||||
|
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthServer creates a new authentication callback server
|
||||||
|
func NewAuthServer() (*AuthServer, error) {
|
||||||
|
// Listen on random available port
|
||||||
|
listener, err := net.Listen("tcp", "localhost:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create listener: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authServer := &AuthServer{
|
||||||
|
listener: listener,
|
||||||
|
result: make(chan WalletAuthResult, 1),
|
||||||
|
err: make(chan error, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/callback", authServer.handleCallback)
|
||||||
|
mux.HandleFunc("/health", authServer.handleHealth)
|
||||||
|
|
||||||
|
authServer.server = &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server in background
|
||||||
|
go func() {
|
||||||
|
if err := authServer.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
|
authServer.err <- fmt.Errorf("auth server error: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return authServer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPort returns the port the server is listening on
|
||||||
|
func (as *AuthServer) GetPort() int {
|
||||||
|
return as.listener.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the authentication server
|
||||||
|
func (as *AuthServer) Close() error {
|
||||||
|
as.mu.Lock()
|
||||||
|
defer as.mu.Unlock()
|
||||||
|
|
||||||
|
if as.done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
as.done = true
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return as.server.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback processes the authentication callback from the gateway
|
||||||
|
func (as *AuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
as.mu.Lock()
|
||||||
|
if as.done {
|
||||||
|
as.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
as.mu.Unlock()
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
query := r.URL.Query()
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
if errMsg := query.Get("error"); errMsg != "" {
|
||||||
|
as.err <- fmt.Errorf("authentication error: %s", errMsg)
|
||||||
|
http.Error(w, "Authentication failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract authentication result
|
||||||
|
result := WalletAuthResult{
|
||||||
|
APIKey: query.Get("api_key"),
|
||||||
|
RefreshToken: query.Get("refresh_token"),
|
||||||
|
Namespace: query.Get("namespace"),
|
||||||
|
Wallet: query.Get("wallet"),
|
||||||
|
Plan: query.Get("plan"),
|
||||||
|
ExpiresAt: query.Get("expires_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if result.APIKey == "" || result.Namespace == "" {
|
||||||
|
as.err <- fmt.Errorf("incomplete authentication response: missing api_key or namespace")
|
||||||
|
http.Error(w, "Incomplete authentication response", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send success response to browser
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #f5f5f5; }
|
||||||
|
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 500px; margin: 0 auto; }
|
||||||
|
.success { color: #4CAF50; font-size: 48px; margin-bottom: 20px; }
|
||||||
|
.details { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; text-align: left; }
|
||||||
|
.key { font-family: monospace; background: #e9ecef; padding: 10px; border-radius: 3px; word-break: break-all; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success">✅</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p>You have successfully authenticated with your wallet.</p>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>🔑 Your Credentials:</h3>
|
||||||
|
<p><strong>API Key:</strong></p>
|
||||||
|
<div class="key">%s</div>
|
||||||
|
<p><strong>Namespace:</strong> %s</p>
|
||||||
|
<p><strong>Wallet:</strong> %s</p>
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Your credentials have been saved securely to <code>~/.debros/credentials.json</code></p>
|
||||||
|
<p><strong>You can now close this browser window and return to your terminal.</strong></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
result.APIKey,
|
||||||
|
result.Namespace,
|
||||||
|
result.Wallet,
|
||||||
|
func() string {
|
||||||
|
if result.Plan != "" {
|
||||||
|
return fmt.Sprintf("<p><strong>Plan:</strong> %s</p>", result.Plan)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send result to waiting goroutine
|
||||||
|
select {
|
||||||
|
case as.result <- result:
|
||||||
|
// Success
|
||||||
|
default:
|
||||||
|
// Channel full, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth provides a simple health check endpoint
|
||||||
|
func (as *AuthServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"server": "debros-auth-callback",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertAuthResult converts WalletAuthResult to Credentials
|
||||||
|
func convertAuthResult(result WalletAuthResult) *Credentials {
|
||||||
|
creds := &Credentials{
|
||||||
|
APIKey: result.APIKey,
|
||||||
|
Namespace: result.Namespace,
|
||||||
|
UserID: result.Wallet,
|
||||||
|
Wallet: result.Wallet,
|
||||||
|
IssuedAt: time.Now(),
|
||||||
|
Plan: result.Plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set refresh token if provided
|
||||||
|
if result.RefreshToken != "" {
|
||||||
|
creds.RefreshToken = result.RefreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expiration if provided
|
||||||
|
if result.ExpiresAt != "" {
|
||||||
|
if expTime, err := time.Parse(time.RFC3339, result.ExpiresAt); err == nil {
|
||||||
|
creds.ExpiresAt = expTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser opens the default browser to the specified URL
|
||||||
|
func openBrowser(url string) error {
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
cmd = "cmd"
|
||||||
|
args = []string{"/c", "start"}
|
||||||
|
case "darwin":
|
||||||
|
cmd = "open"
|
||||||
|
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||||
|
cmd = "xdg-open"
|
||||||
|
}
|
||||||
|
args = append(args, url)
|
||||||
|
|
||||||
|
return exec.Command(cmd, args...).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRandomString generates a cryptographically secure random string
|
||||||
|
func GenerateRandomString(length int) (string, error) {
|
||||||
|
bytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes)[:length], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateWalletAddress validates that a wallet address is properly formatted
|
||||||
|
func ValidateWalletAddress(address string) bool {
|
||||||
|
// Remove 0x prefix if present
|
||||||
|
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||||
|
|
||||||
|
// Check length (Ethereum addresses are 40 hex characters)
|
||||||
|
if len(addr) != 40 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all characters are hex
|
||||||
|
_, err := hex.DecodeString(addr)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatWalletAddress formats a wallet address consistently
|
||||||
|
func FormatWalletAddress(address string) string {
|
||||||
|
addr := strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||||
|
return "0x" + addr
|
||||||
|
}
|
@ -2,7 +2,10 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -41,6 +44,9 @@ type Client struct {
|
|||||||
connected bool
|
connected bool
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// resolvedNamespace is the namespace derived from JWT/APIKey.
|
||||||
|
resolvedNamespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new network client
|
// NewClient creates a new network client
|
||||||
@ -118,6 +124,13 @@ func (c *Client) Connect() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive and set namespace from provided credentials
|
||||||
|
ns, err := c.deriveNamespace()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive namespace: %w", err)
|
||||||
|
}
|
||||||
|
c.resolvedNamespace = ns
|
||||||
|
|
||||||
// Create LibP2P host with optional Anyone proxy for TCP and optional QUIC disable
|
// Create LibP2P host with optional Anyone proxy for TCP and optional QUIC disable
|
||||||
var opts []libp2p.Option
|
var opts []libp2p.Option
|
||||||
opts = append(opts,
|
opts = append(opts,
|
||||||
@ -153,6 +166,8 @@ func (c *Client) Connect() error {
|
|||||||
zap.Strings("listen_addrs", addrStrs),
|
zap.Strings("listen_addrs", addrStrs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
c.logger.Info("Creating GossipSub...")
|
||||||
|
|
||||||
// Create LibP2P GossipSub with PeerExchange enabled (gossip-based peer exchange).
|
// Create LibP2P GossipSub with PeerExchange enabled (gossip-based peer exchange).
|
||||||
// Peer exchange helps propagate peer addresses via pubsub gossip and is enabled
|
// Peer exchange helps propagate peer addresses via pubsub gossip and is enabled
|
||||||
// globally so discovery works without Anchat-specific branches.
|
// globally so discovery works without Anchat-specific branches.
|
||||||
@ -165,17 +180,39 @@ func (c *Client) Connect() error {
|
|||||||
return fmt.Errorf("failed to create pubsub: %w", err)
|
return fmt.Errorf("failed to create pubsub: %w", err)
|
||||||
}
|
}
|
||||||
c.libp2pPS = ps
|
c.libp2pPS = ps
|
||||||
|
c.logger.Info("GossipSub created successfully")
|
||||||
|
|
||||||
// Create pubsub bridge once and store it
|
c.logger.Info("Creating pubsub bridge...")
|
||||||
adapter := pubsub.NewClientAdapter(c.libp2pPS, c.getAppNamespace())
|
|
||||||
c.pubsub = &pubSubBridge{adapter: adapter}
|
|
||||||
|
|
||||||
// Create storage client with the host
|
c.logger.Info("Getting app namespace for pubsub...")
|
||||||
storageClient := storage.NewClient(h, c.getAppNamespace(), c.logger)
|
// Access namespace directly to avoid deadlock (we already hold c.mu.Lock())
|
||||||
|
var namespace string
|
||||||
|
if c.resolvedNamespace != "" {
|
||||||
|
namespace = c.resolvedNamespace
|
||||||
|
} else {
|
||||||
|
namespace = c.config.AppName
|
||||||
|
}
|
||||||
|
c.logger.Info("App namespace retrieved", zap.String("namespace", namespace))
|
||||||
|
|
||||||
|
c.logger.Info("Calling pubsub.NewClientAdapter...")
|
||||||
|
adapter := pubsub.NewClientAdapter(c.libp2pPS, namespace)
|
||||||
|
c.logger.Info("pubsub.NewClientAdapter completed successfully")
|
||||||
|
|
||||||
|
c.logger.Info("Creating pubSubBridge...")
|
||||||
|
c.pubsub = &pubSubBridge{client: c, adapter: adapter}
|
||||||
|
c.logger.Info("Pubsub bridge created successfully")
|
||||||
|
|
||||||
|
c.logger.Info("Creating storage client...")
|
||||||
|
|
||||||
|
// Create storage client with the host (use namespace directly to avoid deadlock)
|
||||||
|
storageClient := storage.NewClient(h, namespace, c.logger)
|
||||||
c.storage = &StorageClientImpl{
|
c.storage = &StorageClientImpl{
|
||||||
client: c,
|
client: c,
|
||||||
storageClient: storageClient,
|
storageClient: storageClient,
|
||||||
}
|
}
|
||||||
|
c.logger.Info("Storage client created successfully")
|
||||||
|
|
||||||
|
c.logger.Info("Starting bootstrap peer connections...")
|
||||||
|
|
||||||
// Connect to bootstrap peers FIRST
|
// Connect to bootstrap peers FIRST
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), c.config.ConnectTimeout)
|
||||||
@ -183,6 +220,7 @@ func (c *Client) Connect() error {
|
|||||||
|
|
||||||
bootstrapPeersConnected := 0
|
bootstrapPeersConnected := 0
|
||||||
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
||||||
|
c.logger.Info("Attempting to connect to bootstrap peer", zap.String("addr", bootstrapAddr))
|
||||||
if err := c.connectToBootstrap(ctx, bootstrapAddr); err != nil {
|
if err := c.connectToBootstrap(ctx, bootstrapAddr); err != nil {
|
||||||
c.logger.Warn("Failed to connect to bootstrap peer",
|
c.logger.Warn("Failed to connect to bootstrap peer",
|
||||||
zap.String("addr", bootstrapAddr),
|
zap.String("addr", bootstrapAddr),
|
||||||
@ -190,12 +228,17 @@ func (c *Client) Connect() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
bootstrapPeersConnected++
|
bootstrapPeersConnected++
|
||||||
|
c.logger.Info("Successfully connected to bootstrap peer", zap.String("addr", bootstrapAddr))
|
||||||
}
|
}
|
||||||
|
|
||||||
if bootstrapPeersConnected == 0 {
|
if bootstrapPeersConnected == 0 {
|
||||||
c.logger.Warn("No bootstrap peers connected, continuing anyway")
|
c.logger.Warn("No bootstrap peers connected, continuing anyway")
|
||||||
|
} else {
|
||||||
|
c.logger.Info("Bootstrap peer connections completed", zap.Int("connected_count", bootstrapPeersConnected))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Adding bootstrap peers to peerstore...")
|
||||||
|
|
||||||
// Add bootstrap peers to peerstore so we can connect to them later
|
// Add bootstrap peers to peerstore so we can connect to them later
|
||||||
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
for _, bootstrapAddr := range c.config.BootstrapPeers {
|
||||||
if ma, err := multiaddr.NewMultiaddr(bootstrapAddr); err == nil {
|
if ma, err := multiaddr.NewMultiaddr(bootstrapAddr); err == nil {
|
||||||
@ -206,6 +249,9 @@ func (c *Client) Connect() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
c.logger.Info("Bootstrap peers added to peerstore")
|
||||||
|
|
||||||
|
c.logger.Info("Starting connection monitoring...")
|
||||||
|
|
||||||
// Client is a lightweight P2P participant - no discovery needed
|
// Client is a lightweight P2P participant - no discovery needed
|
||||||
// We only connect to known bootstrap peers and let nodes handle discovery
|
// We only connect to known bootstrap peers and let nodes handle discovery
|
||||||
@ -213,10 +259,14 @@ func (c *Client) Connect() error {
|
|||||||
|
|
||||||
// Start minimal connection monitoring
|
// Start minimal connection monitoring
|
||||||
c.startConnectionMonitoring()
|
c.startConnectionMonitoring()
|
||||||
|
c.logger.Info("Connection monitoring started")
|
||||||
|
|
||||||
|
c.logger.Info("Setting connected state...")
|
||||||
|
|
||||||
c.connected = true
|
c.connected = true
|
||||||
|
c.logger.Info("Connected state set to true")
|
||||||
|
|
||||||
c.logger.Info("Client connected", zap.String("namespace", c.getAppNamespace()))
|
c.logger.Info("Client connected", zap.String("namespace", namespace))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -290,5 +340,100 @@ func (c *Client) isConnected() bool {
|
|||||||
|
|
||||||
// getAppNamespace returns the namespace for this app
|
// getAppNamespace returns the namespace for this app
|
||||||
func (c *Client) getAppNamespace() string {
|
func (c *Client) getAppNamespace() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.resolvedNamespace != "" {
|
||||||
|
return c.resolvedNamespace
|
||||||
|
}
|
||||||
return c.config.AppName
|
return c.config.AppName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAccess enforces that credentials are present and that any context-based namespace overrides match
|
||||||
|
func (c *Client) requireAccess(ctx context.Context) error {
|
||||||
|
// Allow internal system operations to bypass authentication
|
||||||
|
if IsInternalContext(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := c.Config()
|
||||||
|
if cfg == nil || (strings.TrimSpace(cfg.APIKey) == "" && strings.TrimSpace(cfg.JWT) == "") {
|
||||||
|
return fmt.Errorf("access denied: API key or JWT required")
|
||||||
|
}
|
||||||
|
ns := c.getAppNamespace()
|
||||||
|
if v := ctx.Value(storage.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" && s != ns {
|
||||||
|
return fmt.Errorf("access denied: namespace mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := ctx.Value(pubsub.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" && s != ns {
|
||||||
|
return fmt.Errorf("access denied: namespace mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveNamespace determines the namespace from JWT or API key.
|
||||||
|
func (c *Client) deriveNamespace() (string, error) {
|
||||||
|
// Prefer JWT claim {"Namespace": "..."}
|
||||||
|
if strings.TrimSpace(c.config.JWT) != "" {
|
||||||
|
ns, err := parseJWTNamespace(c.config.JWT)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if ns != "" {
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to API key format ak_<random>:<namespace>
|
||||||
|
if strings.TrimSpace(c.config.APIKey) != "" {
|
||||||
|
ns, err := parseAPIKeyNamespace(c.config.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if ns != "" {
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.config.AppName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWTNamespace decodes base64url payload to extract Namespace claim (no signature verification)
|
||||||
|
func parseJWTNamespace(token string) (string, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", fmt.Errorf("invalid JWT format")
|
||||||
|
}
|
||||||
|
payload := parts[1]
|
||||||
|
// Decode base64url (raw, no padding)
|
||||||
|
data, err := base64.RawURLEncoding.DecodeString(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||||
|
}
|
||||||
|
// Minimal JSON struct
|
||||||
|
var claims struct {
|
||||||
|
Namespace string `json:"Namespace"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &claims); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(claims.Namespace), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAPIKeyNamespace extracts the namespace from ak_<random>:<namespace>
|
||||||
|
func parseAPIKeyNamespace(key string) (string, error) {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return "", fmt.Errorf("invalid API key: empty")
|
||||||
|
}
|
||||||
|
// Allow but ignore prefix ak_
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid API key format: expected ak_<random>:<namespace>")
|
||||||
|
}
|
||||||
|
ns := strings.TrimSpace(parts[1])
|
||||||
|
if ns == "" {
|
||||||
|
return "", fmt.Errorf("invalid API key: empty namespace")
|
||||||
|
}
|
||||||
|
return ns, nil
|
||||||
|
}
|
||||||
|
41
pkg/client/context.go
Normal file
41
pkg/client/context.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/pubsub"
|
||||||
|
"git.debros.io/DeBros/network/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextKey for internal operations
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ctxKeyInternal marks contexts for internal system operations that bypass auth
|
||||||
|
ctxKeyInternal contextKey = "internal_operation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithNamespace applies both storage and pubsub namespace overrides to the context.
|
||||||
|
// It is a convenience helper for client callers to ensure both subsystems receive
|
||||||
|
// the same, consistent namespace override.
|
||||||
|
func WithNamespace(ctx context.Context, ns string) context.Context {
|
||||||
|
ctx = storage.WithNamespace(ctx, ns)
|
||||||
|
ctx = pubsub.WithNamespace(ctx, ns)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithInternalAuth creates a context that bypasses authentication for internal system operations.
|
||||||
|
// This should only be used by the system itself (migrations, internal tasks, etc.)
|
||||||
|
func WithInternalAuth(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKeyInternal, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInternalContext checks if a context is marked for internal operations
|
||||||
|
func IsInternalContext(ctx context.Context) bool {
|
||||||
|
if v := ctx.Value(ctxKeyInternal); v != nil {
|
||||||
|
if internal, ok := v.(bool); ok {
|
||||||
|
return internal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
@ -61,6 +61,10 @@ func (d *DatabaseClientImpl) Query(ctx context.Context, sql string, args ...inte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if this is a read or write operation
|
// Determine if this is a read or write operation
|
||||||
isWriteOperation := d.isWriteOperation(sql)
|
isWriteOperation := d.isWriteOperation(sql)
|
||||||
|
|
||||||
@ -260,6 +264,10 @@ func (d *DatabaseClientImpl) Transaction(ctx context.Context, queries []string)
|
|||||||
return fmt.Errorf("client not connected")
|
return fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
maxRetries := 3
|
maxRetries := 3
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
@ -298,6 +306,10 @@ func (d *DatabaseClientImpl) CreateTable(ctx context.Context, schema string) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
return d.withRetry(func(conn *gorqlite.Connection) error {
|
return d.withRetry(func(conn *gorqlite.Connection) error {
|
||||||
_, err := conn.WriteOne(schema)
|
_, err := conn.WriteOne(schema)
|
||||||
return err
|
return err
|
||||||
@ -323,6 +335,10 @@ func (d *DatabaseClientImpl) GetSchema(ctx context.Context) (*SchemaInfo, error)
|
|||||||
return nil, fmt.Errorf("client not connected")
|
return nil, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := d.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get RQLite connection
|
// Get RQLite connection
|
||||||
conn, err := d.getRQLiteConnection()
|
conn, err := d.getRQLiteConnection()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -396,6 +412,10 @@ func (s *StorageClientImpl) Get(ctx context.Context, key string) ([]byte, error)
|
|||||||
return nil, fmt.Errorf("client not connected")
|
return nil, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
return s.storageClient.Get(ctx, key)
|
return s.storageClient.Get(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,6 +425,10 @@ func (s *StorageClientImpl) Put(ctx context.Context, key string, value []byte) e
|
|||||||
return fmt.Errorf("client not connected")
|
return fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
err := s.storageClient.Put(ctx, key, value)
|
err := s.storageClient.Put(ctx, key, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -419,6 +443,10 @@ func (s *StorageClientImpl) Delete(ctx context.Context, key string) error {
|
|||||||
return fmt.Errorf("client not connected")
|
return fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
err := s.storageClient.Delete(ctx, key)
|
err := s.storageClient.Delete(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -433,6 +461,10 @@ func (s *StorageClientImpl) List(ctx context.Context, prefix string, limit int)
|
|||||||
return nil, fmt.Errorf("client not connected")
|
return nil, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
return s.storageClient.List(ctx, prefix, limit)
|
return s.storageClient.List(ctx, prefix, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,6 +474,10 @@ func (s *StorageClientImpl) Exists(ctx context.Context, key string) (bool, error
|
|||||||
return false, fmt.Errorf("client not connected")
|
return false, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := s.client.requireAccess(ctx); err != nil {
|
||||||
|
return false, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
return s.storageClient.Exists(ctx, key)
|
return s.storageClient.Exists(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,6 +492,10 @@ func (n *NetworkInfoImpl) GetPeers(ctx context.Context) ([]PeerInfo, error) {
|
|||||||
return nil, fmt.Errorf("client not connected")
|
return nil, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := n.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get peers from LibP2P host
|
// Get peers from LibP2P host
|
||||||
host := n.client.host
|
host := n.client.host
|
||||||
if host == nil {
|
if host == nil {
|
||||||
@ -512,6 +552,10 @@ func (n *NetworkInfoImpl) GetStatus(ctx context.Context) (*NetworkStatus, error)
|
|||||||
return nil, fmt.Errorf("client not connected")
|
return nil, fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := n.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
host := n.client.host
|
host := n.client.host
|
||||||
if host == nil {
|
if host == nil {
|
||||||
return nil, fmt.Errorf("no host available")
|
return nil, fmt.Errorf("no host available")
|
||||||
@ -551,6 +595,10 @@ func (n *NetworkInfoImpl) ConnectToPeer(ctx context.Context, peerAddr string) er
|
|||||||
return fmt.Errorf("client not connected")
|
return fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := n.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
host := n.client.host
|
host := n.client.host
|
||||||
if host == nil {
|
if host == nil {
|
||||||
return fmt.Errorf("no host available")
|
return fmt.Errorf("no host available")
|
||||||
@ -582,6 +630,10 @@ func (n *NetworkInfoImpl) DisconnectFromPeer(ctx context.Context, peerID string)
|
|||||||
return fmt.Errorf("client not connected")
|
return fmt.Errorf("client not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := n.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
|
|
||||||
host := n.client.host
|
host := n.client.host
|
||||||
if host == nil {
|
if host == nil {
|
||||||
return fmt.Errorf("no host available")
|
return fmt.Errorf("no host available")
|
||||||
|
@ -129,6 +129,8 @@ type ClientConfig struct {
|
|||||||
RetryAttempts int `json:"retry_attempts"`
|
RetryAttempts int `json:"retry_attempts"`
|
||||||
RetryDelay time.Duration `json:"retry_delay"`
|
RetryDelay time.Duration `json:"retry_delay"`
|
||||||
QuietMode bool `json:"quiet_mode"` // Suppress debug/info logs
|
QuietMode bool `json:"quiet_mode"` // Suppress debug/info logs
|
||||||
|
APIKey string `json:"api_key"` // API key for gateway auth
|
||||||
|
JWT string `json:"jwt"` // Optional JWT bearer token
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultClientConfig returns a default client configuration
|
// DefaultClientConfig returns a default client configuration
|
||||||
@ -145,5 +147,8 @@ func DefaultClientConfig(appName string) *ClientConfig {
|
|||||||
ConnectTimeout: time.Second * 30,
|
ConnectTimeout: time.Second * 30,
|
||||||
RetryAttempts: 3,
|
RetryAttempts: 3,
|
||||||
RetryDelay: time.Second * 5,
|
RetryDelay: time.Second * 5,
|
||||||
|
QuietMode: false,
|
||||||
|
APIKey: "",
|
||||||
|
JWT: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,21 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"git.debros.io/DeBros/network/pkg/pubsub"
|
"git.debros.io/DeBros/network/pkg/pubsub"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pubSubBridge bridges between our PubSubClient interface and the pubsub package
|
// pubSubBridge bridges between our PubSubClient interface and the pubsub package
|
||||||
type pubSubBridge struct {
|
type pubSubBridge struct {
|
||||||
|
client *Client
|
||||||
adapter *pubsub.ClientAdapter
|
adapter *pubsub.ClientAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
|
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
|
||||||
|
if err := p.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
// Convert our MessageHandler to the pubsub package MessageHandler
|
// Convert our MessageHandler to the pubsub package MessageHandler
|
||||||
pubsubHandler := func(topic string, data []byte) error {
|
pubsubHandler := func(topic string, data []byte) error {
|
||||||
return handler(topic, data)
|
return handler(topic, data)
|
||||||
@ -20,13 +25,22 @@ func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler Mess
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) error {
|
func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) error {
|
||||||
|
if err := p.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
return p.adapter.Publish(ctx, topic, data)
|
return p.adapter.Publish(ctx, topic, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
|
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
|
||||||
|
if err := p.client.requireAccess(ctx); err != nil {
|
||||||
|
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
return p.adapter.Unsubscribe(ctx, topic)
|
return p.adapter.Unsubscribe(ctx, topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *pubSubBridge) ListTopics(ctx context.Context) ([]string, error) {
|
func (p *pubSubBridge) ListTopics(ctx context.Context) ([]string, error) {
|
||||||
|
if err := p.client.requireAccess(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||||
|
}
|
||||||
return p.adapter.ListTopics(ctx)
|
return p.adapter.ListTopics(ctx)
|
||||||
}
|
}
|
||||||
|
@ -110,7 +110,7 @@ func DefaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
Discovery: DiscoveryConfig{
|
Discovery: DiscoveryConfig{
|
||||||
BootstrapPeers: []string{
|
BootstrapPeers: []string{
|
||||||
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWRjaa3STPr2PDVai1eqZ2KEc942sbJpxcd42qSAc1P9A2",
|
"/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWGqqR8bxgmYsYrGYMKnUWwZUCpioLmA3H37ggRDnAiFa7",
|
||||||
},
|
},
|
||||||
BootstrapPort: 4001, // Default LibP2P port
|
BootstrapPort: 4001, // Default LibP2P port
|
||||||
DiscoveryInterval: time.Second * 15, // Back to 15 seconds for testing
|
DiscoveryInterval: time.Second * 15, // Back to 15 seconds for testing
|
||||||
|
168
pkg/gateway/apps_handlers.go
Normal file
168
pkg/gateway/apps_handlers.go
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appsHandler implements minimal CRUD for apps within a namespace.
|
||||||
|
// Routes handled:
|
||||||
|
// - GET /v1/apps -> list
|
||||||
|
// - POST /v1/apps -> create
|
||||||
|
// - GET /v1/apps/{app_id} -> fetch
|
||||||
|
// - PUT /v1/apps/{app_id} -> update (name/public_key)
|
||||||
|
// - DELETE /v1/apps/{app_id} -> delete
|
||||||
|
func (g *Gateway) appsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
ns := g.cfg.ClientNamespace
|
||||||
|
if v := ctx.Value(storage.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ns) == "" { ns = "default" }
|
||||||
|
db := g.client.Database()
|
||||||
|
nsID, err := g.resolveNamespaceID(ctx, ns)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
// Determine if operating on collection or single resource
|
||||||
|
if path == "/v1/apps" || path == "/v1/apps/" {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// List apps
|
||||||
|
res, err := db.Query(ctx, "SELECT app_id, name, public_key, created_at FROM apps WHERE namespace_id = ? ORDER BY created_at DESC", nsID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, res.Count)
|
||||||
|
for _, row := range res.Rows {
|
||||||
|
item := map[string]any{
|
||||||
|
"app_id": row[0],
|
||||||
|
"name": row[1],
|
||||||
|
"public_key": row[2],
|
||||||
|
"namespace": ns,
|
||||||
|
"created_at": row[3],
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "count": len(items)})
|
||||||
|
return
|
||||||
|
case http.MethodPost:
|
||||||
|
// Create app with provided name/public_key
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Generate app_id
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to generate app id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appID := "app_" + base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
if _, err := db.Query(ctx, "INSERT INTO apps(namespace_id, app_id, name, public_key) VALUES (?, ?, ?, ?)", nsID, appID, req.Name, req.PublicKey); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"app_id": appID,
|
||||||
|
"name": req.Name,
|
||||||
|
"public_key": req.PublicKey,
|
||||||
|
"namespace": ns,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single resource: /v1/apps/{app_id}
|
||||||
|
if strings.HasPrefix(path, "/v1/apps/") {
|
||||||
|
appID := strings.TrimPrefix(path, "/v1/apps/")
|
||||||
|
appID = strings.TrimSpace(appID)
|
||||||
|
if appID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing app_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
res, err := db.Query(ctx, "SELECT app_id, name, public_key, created_at FROM apps WHERE namespace_id = ? AND app_id = ? LIMIT 1", nsID, appID)
|
||||||
|
if err != nil || res == nil || res.Count == 0 {
|
||||||
|
writeError(w, http.StatusNotFound, "app not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row := res.Rows[0]
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"app_id": row[0],
|
||||||
|
"name": row[1],
|
||||||
|
"public_key": row[2],
|
||||||
|
"namespace": ns,
|
||||||
|
"created_at": row[3],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
PublicKey *string `json:"public_key"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid json body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build update dynamically
|
||||||
|
sets := make([]string, 0, 2)
|
||||||
|
args := make([]any, 0, 4)
|
||||||
|
if req.Name != nil {
|
||||||
|
sets = append(sets, "name = ?")
|
||||||
|
args = append(args, *req.Name)
|
||||||
|
}
|
||||||
|
if req.PublicKey != nil {
|
||||||
|
sets = append(sets, "public_key = ?")
|
||||||
|
args = append(args, *req.PublicKey)
|
||||||
|
}
|
||||||
|
if len(sets) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "no fields to update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := "UPDATE apps SET " + strings.Join(sets, ", ") + " WHERE namespace_id = ? AND app_id = ?"
|
||||||
|
args = append(args, nsID, appID)
|
||||||
|
if _, err := db.Query(ctx, q, args...); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
return
|
||||||
|
case http.MethodDelete:
|
||||||
|
if _, err := db.Query(ctx, "DELETE FROM apps WHERE namespace_id = ? AND app_id = ?", nsID, appID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
}
|
1028
pkg/gateway/auth_handlers.go
Normal file
1028
pkg/gateway/auth_handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
23
pkg/gateway/db_helpers.go
Normal file
23
pkg/gateway/db_helpers.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g *Gateway) resolveNamespaceID(ctx context.Context, ns string) (interface{}, error) {
|
||||||
|
// Use internal context to bypass authentication for system operations
|
||||||
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
|
db := g.client.Database()
|
||||||
|
if _, err := db.Query(internalCtx, "INSERT OR IGNORE INTO namespaces(name) VALUES (?)", ns); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err := db.Query(internalCtx, "SELECT id FROM namespaces WHERE name = ? LIMIT 1", ns)
|
||||||
|
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res.Rows[0][0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: seeding API keys from config is removed.
|
142
pkg/gateway/gateway.go
Normal file
142
pkg/gateway/gateway.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/client"
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds configuration for the gateway server
|
||||||
|
type Config struct {
|
||||||
|
ListenAddr string
|
||||||
|
ClientNamespace string
|
||||||
|
BootstrapPeers []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Gateway struct {
|
||||||
|
logger *logging.ColoredLogger
|
||||||
|
cfg *Config
|
||||||
|
client client.NetworkClient
|
||||||
|
startedAt time.Time
|
||||||
|
signingKey *rsa.PrivateKey
|
||||||
|
keyID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates and initializes a new Gateway instance
|
||||||
|
func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Building client config...")
|
||||||
|
|
||||||
|
// Build client config from gateway cfg
|
||||||
|
cliCfg := client.DefaultClientConfig(cfg.ClientNamespace)
|
||||||
|
if len(cfg.BootstrapPeers) > 0 {
|
||||||
|
cliCfg.BootstrapPeers = cfg.BootstrapPeers
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Creating network client...")
|
||||||
|
c, err := client.NewClient(cliCfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentClient, "failed to create network client", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Connecting network client...")
|
||||||
|
if err := c.Connect(); err != nil {
|
||||||
|
logger.ComponentError(logging.ComponentClient, "failed to connect network client", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentClient, "Network client connected",
|
||||||
|
zap.String("namespace", cliCfg.AppName),
|
||||||
|
zap.Int("bootstrap_peer_count", len(cliCfg.BootstrapPeers)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Creating gateway instance...")
|
||||||
|
gw := &Gateway{
|
||||||
|
logger: logger,
|
||||||
|
cfg: cfg,
|
||||||
|
client: c,
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Generating RSA signing key...")
|
||||||
|
// Generate local RSA signing key for JWKS/JWT (ephemeral for now)
|
||||||
|
if key, err := rsa.GenerateKey(rand.Reader, 2048); err == nil {
|
||||||
|
gw.signingKey = key
|
||||||
|
gw.keyID = "gw-" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "RSA key generated successfully")
|
||||||
|
} else {
|
||||||
|
logger.ComponentWarn(logging.ComponentGeneral, "failed to generate RSA key; jwks will be empty", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Starting database migrations goroutine...")
|
||||||
|
// Non-blocking DB migrations: probe RQLite; if reachable, apply migrations asynchronously
|
||||||
|
go func() {
|
||||||
|
if gw.probeRQLiteReachable(3 * time.Second) {
|
||||||
|
internalCtx := gw.withInternalAuth(context.Background())
|
||||||
|
if err := gw.applyMigrations(internalCtx); err != nil {
|
||||||
|
if err == errNoMigrationsFound {
|
||||||
|
if err2 := gw.applyAutoMigrations(internalCtx); err2 != nil {
|
||||||
|
logger.ComponentWarn(logging.ComponentDatabase, "auto migrations failed", zap.Error(err2))
|
||||||
|
} else {
|
||||||
|
logger.ComponentInfo(logging.ComponentDatabase, "auto migrations applied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.ComponentWarn(logging.ComponentDatabase, "migrations failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.ComponentInfo(logging.ComponentDatabase, "migrations applied")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.ComponentWarn(logging.ComponentDatabase, "RQLite not reachable; skipping migrations for now")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.ComponentInfo(logging.ComponentGeneral, "Gateway creation completed, returning...")
|
||||||
|
return gw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withInternalAuth creates a context for internal gateway operations that bypass authentication
|
||||||
|
func (g *Gateway) withInternalAuth(ctx context.Context) context.Context {
|
||||||
|
return client.WithInternalAuth(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeRQLiteReachable performs a quick GET /status against candidate endpoints with a short timeout.
|
||||||
|
func (g *Gateway) probeRQLiteReachable(timeout time.Duration) bool {
|
||||||
|
endpoints := client.DefaultDatabaseEndpoints()
|
||||||
|
httpClient := &http.Client{Timeout: timeout}
|
||||||
|
for _, ep := range endpoints {
|
||||||
|
url := ep
|
||||||
|
if url == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if url[len(url)-1] == '/' {
|
||||||
|
url = url[:len(url)-1]
|
||||||
|
}
|
||||||
|
reqURL := url + "/status"
|
||||||
|
resp, err := httpClient.Get(reqURL)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close disconnects the gateway client
|
||||||
|
func (g *Gateway) Close() {
|
||||||
|
if g.client != nil {
|
||||||
|
if err := g.client.Disconnect(); err != nil {
|
||||||
|
g.logger.ComponentWarn(logging.ComponentClient, "error during client disconnect", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
pkg/gateway/http_helpers.go
Normal file
35
pkg/gateway/http_helpers.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
bytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusResponseWriter) WriteHeader(code int) {
|
||||||
|
w.status = code
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
n, err := w.ResponseWriter.Write(b)
|
||||||
|
w.bytes += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes JSON with status code
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes a standardized JSON error
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]any{"error": msg})
|
||||||
|
}
|
157
pkg/gateway/jwt.go
Normal file
157
pkg/gateway/jwt.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g *Gateway) jwksHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if g.signingKey == nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pub := g.signingKey.Public().(*rsa.PublicKey)
|
||||||
|
n := pub.N.Bytes()
|
||||||
|
// Encode exponent as big-endian bytes
|
||||||
|
eVal := pub.E
|
||||||
|
eb := make([]byte, 0)
|
||||||
|
for eVal > 0 {
|
||||||
|
eb = append([]byte{byte(eVal & 0xff)}, eb...)
|
||||||
|
eVal >>= 8
|
||||||
|
}
|
||||||
|
if len(eb) == 0 {
|
||||||
|
eb = []byte{0}
|
||||||
|
}
|
||||||
|
jwk := map[string]string{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"kid": g.keyID,
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(n),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(eb),
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal types for JWT handling
|
||||||
|
type jwtHeader struct {
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Typ string `json:"typ"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwtClaims struct {
|
||||||
|
Iss string `json:"iss"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
Iat int64 `json:"iat"`
|
||||||
|
Nbf int64 `json:"nbf"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAndVerifyJWT verifies an RS256 JWT created by this gateway and returns claims
|
||||||
|
func (g *Gateway) parseAndVerifyJWT(token string) (*jwtClaims, error) {
|
||||||
|
if g.signingKey == nil {
|
||||||
|
return nil, errors.New("signing key unavailable")
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid header encoding")
|
||||||
|
}
|
||||||
|
pb, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid payload encoding")
|
||||||
|
}
|
||||||
|
sb, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid signature encoding")
|
||||||
|
}
|
||||||
|
var header jwtHeader
|
||||||
|
if err := json.Unmarshal(hb, &header); err != nil {
|
||||||
|
return nil, errors.New("invalid header json")
|
||||||
|
}
|
||||||
|
if header.Alg != "RS256" {
|
||||||
|
return nil, errors.New("unsupported alg")
|
||||||
|
}
|
||||||
|
// Verify signature
|
||||||
|
signingInput := parts[0] + "." + parts[1]
|
||||||
|
sum := sha256.Sum256([]byte(signingInput))
|
||||||
|
pub := g.signingKey.Public().(*rsa.PublicKey)
|
||||||
|
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sb); err != nil {
|
||||||
|
return nil, errors.New("invalid signature")
|
||||||
|
}
|
||||||
|
// Parse claims
|
||||||
|
var claims jwtClaims
|
||||||
|
if err := json.Unmarshal(pb, &claims); err != nil {
|
||||||
|
return nil, errors.New("invalid claims json")
|
||||||
|
}
|
||||||
|
// Validate issuer
|
||||||
|
if claims.Iss != "debros-gateway" {
|
||||||
|
return nil, errors.New("invalid issuer")
|
||||||
|
}
|
||||||
|
// Validate registered claims
|
||||||
|
now := time.Now().Unix()
|
||||||
|
// allow small clock skew ±60s
|
||||||
|
const skew = int64(60)
|
||||||
|
if claims.Nbf != 0 && now+skew < claims.Nbf {
|
||||||
|
return nil, errors.New("token not yet valid")
|
||||||
|
}
|
||||||
|
if claims.Exp != 0 && now-skew > claims.Exp {
|
||||||
|
return nil, errors.New("token expired")
|
||||||
|
}
|
||||||
|
if claims.Iat != 0 && claims.Iat-skew > now {
|
||||||
|
return nil, errors.New("invalid iat")
|
||||||
|
}
|
||||||
|
if claims.Aud != "gateway" {
|
||||||
|
return nil, errors.New("invalid audience")
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) generateJWT(ns, subject string, ttl time.Duration) (string, int64, error) {
|
||||||
|
if g.signingKey == nil {
|
||||||
|
return "", 0, errors.New("signing key unavailable")
|
||||||
|
}
|
||||||
|
header := map[string]string{
|
||||||
|
"alg": "RS256",
|
||||||
|
"typ": "JWT",
|
||||||
|
"kid": g.keyID,
|
||||||
|
}
|
||||||
|
hb, _ := json.Marshal(header)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
exp := now.Add(ttl)
|
||||||
|
payload := map[string]any{
|
||||||
|
"iss": "debros-gateway",
|
||||||
|
"sub": subject,
|
||||||
|
"aud": "gateway",
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"nbf": now.Unix(),
|
||||||
|
"exp": exp.Unix(),
|
||||||
|
"namespace": ns,
|
||||||
|
}
|
||||||
|
pb, _ := json.Marshal(payload)
|
||||||
|
hb64 := base64.RawURLEncoding.EncodeToString(hb)
|
||||||
|
pb64 := base64.RawURLEncoding.EncodeToString(pb)
|
||||||
|
signingInput := hb64 + "." + pb64
|
||||||
|
sum := sha256.Sum256([]byte(signingInput))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, g.signingKey, crypto.SHA256, sum[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
sb64 := base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return signingInput + "." + sb64, exp.Unix(), nil
|
||||||
|
}
|
354
pkg/gateway/middleware.go
Normal file
354
pkg/gateway/middleware.go
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"git.debros.io/DeBros/network/pkg/storage"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// context keys for request-scoped auth metadata (private to package)
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxKeyAPIKey contextKey = "api_key"
|
||||||
|
ctxKeyJWT contextKey = "jwt_claims"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withMiddleware adds CORS and logging middleware
|
||||||
|
func (g *Gateway) withMiddleware(next http.Handler) http.Handler {
|
||||||
|
// Order: logging (outermost) -> CORS -> auth -> handler
|
||||||
|
// Add authorization layer after auth to enforce namespace ownership
|
||||||
|
return g.loggingMiddleware(g.corsMiddleware(g.authMiddleware(g.authorizationMiddleware(next))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware logs basic request info and duration
|
||||||
|
func (g *Gateway) loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
srw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(srw, r)
|
||||||
|
dur := time.Since(start)
|
||||||
|
g.logger.ComponentInfo(logging.ComponentGeneral, "request",
|
||||||
|
zap.String("method", r.Method),
|
||||||
|
zap.String("path", r.URL.Path),
|
||||||
|
zap.Int("status", srw.status),
|
||||||
|
zap.Int("bytes", srw.bytes),
|
||||||
|
zap.String("duration", dur.String()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Persist request log asynchronously (best-effort)
|
||||||
|
go g.persistRequestLog(r, srw, dur)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware enforces auth when enabled via config.
|
||||||
|
// Accepts:
|
||||||
|
// - Authorization: Bearer <JWT> (RS256 issued by this gateway)
|
||||||
|
// - Authorization: Bearer <API key> or ApiKey <API key>
|
||||||
|
// - X-API-Key: <API key>
|
||||||
|
func (g *Gateway) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Allow preflight without auth
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Allow public endpoints without auth
|
||||||
|
if isPublicPath(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Try JWT Bearer first if Authorization looks like one
|
||||||
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||||
|
lower := strings.ToLower(auth)
|
||||||
|
if strings.HasPrefix(lower, "bearer ") {
|
||||||
|
tok := strings.TrimSpace(auth[len("Bearer "):])
|
||||||
|
if strings.Count(tok, ".") == 2 {
|
||||||
|
if claims, err := g.parseAndVerifyJWT(tok); err == nil {
|
||||||
|
// Attach JWT claims and namespace to context
|
||||||
|
ctx := context.WithValue(r.Context(), ctxKeyJWT, claims)
|
||||||
|
if ns := strings.TrimSpace(claims.Namespace); ns != "" {
|
||||||
|
ctx = storage.WithNamespace(ctx, ns)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If it looked like a JWT but failed verification, fall through to API key check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback to API key (validate against DB)
|
||||||
|
key := extractAPIKey(r)
|
||||||
|
if key == "" {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer realm=\"gateway\", charset=\"UTF-8\"")
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up API key in DB and derive namespace
|
||||||
|
db := g.client.Database()
|
||||||
|
ctx := r.Context()
|
||||||
|
// Join to namespaces to resolve name in one query
|
||||||
|
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(ctx, q, key)
|
||||||
|
if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Extract namespace name
|
||||||
|
var ns string
|
||||||
|
if s, ok := res.Rows[0][0].(string); ok {
|
||||||
|
ns = strings.TrimSpace(s)
|
||||||
|
} else {
|
||||||
|
b, _ := json.Marshal(res.Rows[0][0])
|
||||||
|
_ = json.Unmarshal(b, &ns)
|
||||||
|
ns = strings.TrimSpace(ns)
|
||||||
|
}
|
||||||
|
if ns == "" {
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer error=\"invalid_token\"")
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach auth metadata to context for downstream use
|
||||||
|
ctx = context.WithValue(ctx, ctxKeyAPIKey, key)
|
||||||
|
ctx = storage.WithNamespace(ctx, ns)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAPIKey extracts API key from Authorization or X-API-Key
|
||||||
|
func extractAPIKey(r *http.Request) string {
|
||||||
|
// Prefer Authorization header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "" {
|
||||||
|
// Support "Bearer <token>" and "ApiKey <token>"
|
||||||
|
lower := strings.ToLower(auth)
|
||||||
|
if strings.HasPrefix(lower, "bearer ") {
|
||||||
|
return strings.TrimSpace(auth[len("Bearer "):])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, "apikey ") {
|
||||||
|
return strings.TrimSpace(auth[len("ApiKey "):])
|
||||||
|
}
|
||||||
|
// If header has no scheme, treat the whole value as token (lenient for dev)
|
||||||
|
if !strings.Contains(auth, " ") {
|
||||||
|
return strings.TrimSpace(auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback header
|
||||||
|
if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicPath returns true for routes that should be accessible without API key auth
|
||||||
|
func isPublicPath(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case "/health", "/v1/health", "/status", "/v1/status", "/v1/auth/jwks", "/.well-known/jwks.json", "/v1/version", "/v1/auth/login", "/v1/auth/challenge", "/v1/auth/verify", "/v1/auth/register", "/v1/auth/refresh", "/v1/auth/logout", "/v1/auth/api-key":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizationMiddleware enforces that the authenticated actor owns the namespace
|
||||||
|
// for certain protected paths (e.g., apps CRUD and storage APIs).
|
||||||
|
func (g *Gateway) authorizationMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip for public/OPTIONS paths only
|
||||||
|
if r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exempt whoami from ownership enforcement so users can inspect their session
|
||||||
|
if r.URL.Path == "/v1/auth/whoami" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only enforce for specific resource paths
|
||||||
|
if !requiresNamespaceOwnership(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine namespace from context
|
||||||
|
ctx := r.Context()
|
||||||
|
ns := ""
|
||||||
|
if v := ctx.Value(storage.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
ns = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ns == "" && g.cfg != nil {
|
||||||
|
ns = strings.TrimSpace(g.cfg.ClientNamespace)
|
||||||
|
}
|
||||||
|
if ns == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify actor from context
|
||||||
|
ownerType := ""
|
||||||
|
ownerID := ""
|
||||||
|
if v := ctx.Value(ctxKeyJWT); v != nil {
|
||||||
|
if claims, ok := v.(*jwtClaims); ok && claims != nil && strings.TrimSpace(claims.Sub) != "" {
|
||||||
|
ownerType = "wallet"
|
||||||
|
ownerID = strings.TrimSpace(claims.Sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownerType == "" && ownerID == "" {
|
||||||
|
if v := ctx.Value(ctxKeyAPIKey); v != nil {
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
ownerType = "api_key"
|
||||||
|
ownerID = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ownerType == "" || ownerID == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "missing identity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership in DB
|
||||||
|
db := g.client.Database()
|
||||||
|
// Ensure namespace exists and get id
|
||||||
|
if _, err := db.Query(ctx, "INSERT OR IGNORE INTO namespaces(name) VALUES (?)", ns); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nres, err := db.Query(ctx, "SELECT id FROM namespaces WHERE name = ? LIMIT 1", ns)
|
||||||
|
if err != nil || nres == nil || nres.Count == 0 || len(nres.Rows) == 0 || len(nres.Rows[0]) == 0 {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nsID := nres.Rows[0][0]
|
||||||
|
|
||||||
|
q := "SELECT 1 FROM namespace_ownership WHERE namespace_id = ? AND owner_type = ? AND owner_id = ? LIMIT 1"
|
||||||
|
res, err := db.Query(ctx, q, nsID, ownerType, ownerID)
|
||||||
|
if err != nil || res == nil || res.Count == 0 {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden: not an owner of namespace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requiresNamespaceOwnership returns true if the path should be guarded by
|
||||||
|
// namespace ownership checks.
|
||||||
|
func requiresNamespaceOwnership(p string) bool {
|
||||||
|
if p == "/storage" || p == "/v1/storage" || strings.HasPrefix(p, "/v1/storage/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if p == "/v1/apps" || strings.HasPrefix(p, "/v1/apps/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, "/v1/pubsub") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware applies permissive CORS headers suitable for early development
|
||||||
|
func (g *Gateway) corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(600))
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistRequestLog writes request metadata to the database (best-effort)
|
||||||
|
func (g *Gateway) persistRequestLog(r *http.Request, srw *statusResponseWriter, dur time.Duration) {
|
||||||
|
if g.client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Use a short timeout to avoid blocking shutdowns
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db := g.client.Database()
|
||||||
|
|
||||||
|
// Resolve API key ID if available
|
||||||
|
var apiKeyID interface{} = nil
|
||||||
|
if v := r.Context().Value(ctxKeyAPIKey); v != nil {
|
||||||
|
if key, ok := v.(string); ok && key != "" {
|
||||||
|
if res, err := db.Query(ctx, "SELECT id FROM api_keys WHERE key = ? LIMIT 1", key); err == nil {
|
||||||
|
if res != nil && res.Count > 0 && len(res.Rows) > 0 && len(res.Rows[0]) > 0 {
|
||||||
|
switch idv := res.Rows[0][0].(type) {
|
||||||
|
case int64:
|
||||||
|
apiKeyID = idv
|
||||||
|
case float64:
|
||||||
|
apiKeyID = int64(idv)
|
||||||
|
case int:
|
||||||
|
apiKeyID = int64(idv)
|
||||||
|
case string:
|
||||||
|
// best effort parse
|
||||||
|
if n, err := strconv.Atoi(idv); err == nil {
|
||||||
|
apiKeyID = int64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := getClientIP(r)
|
||||||
|
|
||||||
|
// Insert the log row
|
||||||
|
_, _ = db.Query(ctx,
|
||||||
|
"INSERT INTO request_logs (method, path, status_code, bytes_out, duration_ms, ip, api_key_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
r.Method,
|
||||||
|
r.URL.Path,
|
||||||
|
srw.status,
|
||||||
|
srw.bytes,
|
||||||
|
dur.Milliseconds(),
|
||||||
|
ip,
|
||||||
|
apiKeyID,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update last_used_at for the API key if present
|
||||||
|
if apiKeyID != nil {
|
||||||
|
_, _ = db.Query(ctx, "UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", apiKeyID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientIP extracts the client IP from headers or RemoteAddr
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
// X-Forwarded-For may contain a list of IPs, take the first
|
||||||
|
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xr := strings.TrimSpace(r.Header.Get("X-Real-IP")); xr != "" {
|
||||||
|
return xr
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
188
pkg/gateway/migrate.go
Normal file
188
pkg/gateway/migrate.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/client"
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoMigrationsFound = errors.New("no migrations found")
|
||||||
|
|
||||||
|
func (g *Gateway) applyAutoMigrations(ctx context.Context) error {
|
||||||
|
if g.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := g.client.Database()
|
||||||
|
|
||||||
|
// Use internal context to bypass authentication for system migrations
|
||||||
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
|
|
||||||
|
stmts := []string{
|
||||||
|
// namespaces
|
||||||
|
"CREATE TABLE IF NOT EXISTS namespaces (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t name TEXT NOT NULL UNIQUE,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)",
|
||||||
|
// api_keys
|
||||||
|
"CREATE TABLE IF NOT EXISTS api_keys (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t key TEXT NOT NULL UNIQUE,\n\t name TEXT,\n\t namespace_id INTEGER NOT NULL,\n\t scopes TEXT,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\t last_used_at TIMESTAMP,\n\t FOREIGN KEY(namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE\n)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_api_keys_namespace ON api_keys(namespace_id)",
|
||||||
|
// request_logs
|
||||||
|
"CREATE TABLE IF NOT EXISTS request_logs (\n\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n\t method TEXT NOT NULL,\n\t path TEXT NOT NULL,\n\t status_code INTEGER NOT NULL,\n\t bytes_out INTEGER NOT NULL DEFAULT 0,\n\t duration_ms INTEGER NOT NULL DEFAULT 0,\n\t ip TEXT,\n\t api_key_id INTEGER,\n\t created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\t FOREIGN KEY(api_key_id) REFERENCES api_keys(id) ON DELETE SET NULL\n)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_api_key ON request_logs(api_key_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at)",
|
||||||
|
// seed default namespace
|
||||||
|
"INSERT OR IGNORE INTO namespaces(name) VALUES ('default')",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := db.Query(internalCtx, stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) applyMigrations(ctx context.Context) error {
|
||||||
|
if g.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := g.client.Database()
|
||||||
|
|
||||||
|
// Use internal context to bypass authentication for system migrations
|
||||||
|
internalCtx := client.WithInternalAuth(ctx)
|
||||||
|
|
||||||
|
// Ensure schema_migrations exists first
|
||||||
|
if _, err := db.Query(internalCtx, "CREATE TABLE IF NOT EXISTS schema_migrations (\n\tversion INTEGER PRIMARY KEY,\n\tapplied_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))\n)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate migrations directory relative to CWD
|
||||||
|
migDir := "migrations"
|
||||||
|
if fi, err := os.Stat(migDir); err != nil || !fi.IsDir() {
|
||||||
|
return errNoMigrationsFound
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(migDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type mig struct {
|
||||||
|
ver int
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
migrations := make([]mig, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ver, ok := parseMigrationVersion(name); ok {
|
||||||
|
migrations = append(migrations, mig{ver: ver, path: filepath.Join(migDir, name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(migrations) == 0 {
|
||||||
|
return errNoMigrationsFound
|
||||||
|
}
|
||||||
|
sort.Slice(migrations, func(i, j int) bool { return migrations[i].ver < migrations[j].ver })
|
||||||
|
|
||||||
|
// Helper to check if version applied
|
||||||
|
isApplied := func(ctx context.Context, v int) (bool, error) {
|
||||||
|
res, err := db.Query(ctx, "SELECT 1 FROM schema_migrations WHERE version = ? LIMIT 1", v)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res != nil && res.Count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
applied, err := isApplied(internalCtx, m.ver)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Read and split SQL file into statements
|
||||||
|
content, err := os.ReadFile(m.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmts := splitSQLStatements(string(content))
|
||||||
|
for _, s := range stmts {
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := db.Query(internalCtx, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark as applied
|
||||||
|
if _, err := db.Query(internalCtx, "INSERT INTO schema_migrations (version) VALUES (?)", m.ver); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.logger.ComponentInfo(logging.ComponentDatabase, "applied migration", zap.Int("version", m.ver), zap.String("file", m.path))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMigrationVersion(name string) (int, bool) {
|
||||||
|
i := 0
|
||||||
|
for i < len(name) && name[i] >= '0' && name[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(name[:i])
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSQLStatements(sqlText string) []string {
|
||||||
|
lines := strings.Split(sqlText, "\n")
|
||||||
|
cleaned := make([]string, 0, len(lines))
|
||||||
|
for _, ln := range lines {
|
||||||
|
s := strings.TrimSpace(ln)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Handle inline comments by removing everything after --
|
||||||
|
if commentIdx := strings.Index(s, "--"); commentIdx >= 0 {
|
||||||
|
s = strings.TrimSpace(s[:commentIdx])
|
||||||
|
if s == "" {
|
||||||
|
continue // line was only a comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(s)
|
||||||
|
if upper == "BEGIN;" || upper == "COMMIT;" || upper == "BEGIN" || upper == "COMMIT" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(upper, "INSERT") && strings.Contains(upper, "SCHEMA_MIGRATIONS") {
|
||||||
|
// ignore in-file migration markers
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, s)
|
||||||
|
}
|
||||||
|
// Join and split by ';'
|
||||||
|
joined := strings.Join(cleaned, "\n")
|
||||||
|
parts := strings.Split(joined, ";")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
sp := strings.TrimSpace(p)
|
||||||
|
if sp == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, sp+";")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
194
pkg/gateway/pubsub_handlers.go
Normal file
194
pkg/gateway/pubsub_handlers.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/storage"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
// For early development we accept any origin; tighten later.
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubsubWebsocketHandler upgrades to WS, subscribes to a namespaced topic, and
|
||||||
|
// forwards received PubSub messages to the client. Messages sent by the client
|
||||||
|
// are published to the same namespaced topic.
|
||||||
|
func (g *Gateway) pubsubWebsocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve namespace from auth context
|
||||||
|
ns := resolveNamespaceFromRequest(r)
|
||||||
|
if ns == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topic := r.URL.Query().Get("topic")
|
||||||
|
if topic == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'topic'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullTopic := namespacedTopic(ns, topic)
|
||||||
|
|
||||||
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Channel to deliver PubSub messages to WS writer
|
||||||
|
msgs := make(chan []byte, 128)
|
||||||
|
ctx := r.Context()
|
||||||
|
// Subscribe to the topic; push data into msgs
|
||||||
|
h := func(_ string, data []byte) error {
|
||||||
|
select {
|
||||||
|
case msgs <- data:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// Drop if client is slow to avoid blocking network
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := g.client.PubSub().Subscribe(ctx, fullTopic, h); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = g.client.PubSub().Unsubscribe(ctx, fullTopic) }()
|
||||||
|
|
||||||
|
// Writer loop
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case b, ok := <-msgs:
|
||||||
|
if !ok {
|
||||||
|
_ = conn.WriteControl(websocket.CloseMessage, []byte{}, time.Now().Add(5*time.Second))
|
||||||
|
close(done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
|
||||||
|
close(done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
// Ping keepalive
|
||||||
|
_ = conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second))
|
||||||
|
case <-ctx.Done():
|
||||||
|
close(done)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Reader loop: treat any client message as publish to the same topic
|
||||||
|
for {
|
||||||
|
mt, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if mt != websocket.TextMessage && mt != websocket.BinaryMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := g.client.PubSub().Publish(ctx, fullTopic, data); err != nil {
|
||||||
|
// Best-effort notify client
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, []byte("publish_error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubsubPublishHandler handles POST /v1/pubsub/publish {topic, data_base64}
|
||||||
|
func (g *Gateway) pubsubPublishHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ns := resolveNamespaceFromRequest(r)
|
||||||
|
if ns == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
DataB64 string `json:"data_base64"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Topic == "" || body.DataB64 == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body: expected {topic,data_base64}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(body.DataB64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid base64 data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.PubSub().Publish(r.Context(), namespacedTopic(ns, body.Topic), data); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pubsubTopicsHandler lists topics within the caller's namespace
|
||||||
|
func (g *Gateway) pubsubTopicsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ns := resolveNamespaceFromRequest(r)
|
||||||
|
if ns == "" {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace not resolved")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all, err := g.client.PubSub().ListTopics(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := namespacePrefix(ns)
|
||||||
|
var filtered []string
|
||||||
|
for _, t := range all {
|
||||||
|
if len(t) >= len(prefix) && t[:len(prefix)] == prefix {
|
||||||
|
filtered = append(filtered, t[len(prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"topics": filtered})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNamespaceFromRequest gets namespace from context set by auth middleware
|
||||||
|
func resolveNamespaceFromRequest(r *http.Request) string {
|
||||||
|
if v := r.Context().Value(storage.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func namespacePrefix(ns string) string {
|
||||||
|
return "ns::" + ns + "::"
|
||||||
|
}
|
||||||
|
|
||||||
|
func namespacedTopic(ns, topic string) string {
|
||||||
|
return namespacePrefix(ns) + topic
|
||||||
|
}
|
54
pkg/gateway/routes.go
Normal file
54
pkg/gateway/routes.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Routes returns the http.Handler with all routes and middleware configured
|
||||||
|
func (g *Gateway) Routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// root and v1 health/status
|
||||||
|
mux.HandleFunc("/health", g.healthHandler)
|
||||||
|
mux.HandleFunc("/status", g.statusHandler)
|
||||||
|
mux.HandleFunc("/v1/health", g.healthHandler)
|
||||||
|
mux.HandleFunc("/v1/version", g.versionHandler)
|
||||||
|
mux.HandleFunc("/v1/status", g.statusHandler)
|
||||||
|
|
||||||
|
// auth endpoints
|
||||||
|
mux.HandleFunc("/v1/auth/jwks", g.jwksHandler)
|
||||||
|
mux.HandleFunc("/.well-known/jwks.json", g.jwksHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/login", g.loginPageHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/challenge", g.challengeHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/verify", g.verifyHandler)
|
||||||
|
// New: issue JWT from API key; new: create or return API key for a wallet after verification
|
||||||
|
mux.HandleFunc("/v1/auth/token", g.apiKeyToJWTHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/api-key", g.issueAPIKeyHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/register", g.registerHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/refresh", g.refreshHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/logout", g.logoutHandler)
|
||||||
|
mux.HandleFunc("/v1/auth/whoami", g.whoamiHandler)
|
||||||
|
|
||||||
|
// apps CRUD
|
||||||
|
mux.HandleFunc("/v1/apps", g.appsHandler)
|
||||||
|
mux.HandleFunc("/v1/apps/", g.appsHandler)
|
||||||
|
|
||||||
|
// storage
|
||||||
|
mux.HandleFunc("/v1/storage", g.storageHandler) // legacy/basic
|
||||||
|
mux.HandleFunc("/v1/storage/get", g.storageGetHandler)
|
||||||
|
mux.HandleFunc("/v1/storage/put", g.storagePutHandler)
|
||||||
|
mux.HandleFunc("/v1/storage/delete", g.storageDeleteHandler)
|
||||||
|
mux.HandleFunc("/v1/storage/list", g.storageListHandler)
|
||||||
|
mux.HandleFunc("/v1/storage/exists", g.storageExistsHandler)
|
||||||
|
|
||||||
|
// network
|
||||||
|
mux.HandleFunc("/v1/network/status", g.networkStatusHandler)
|
||||||
|
mux.HandleFunc("/v1/network/peers", g.networkPeersHandler)
|
||||||
|
mux.HandleFunc("/v1/network/connect", g.networkConnectHandler)
|
||||||
|
mux.HandleFunc("/v1/network/disconnect", g.networkDisconnectHandler)
|
||||||
|
|
||||||
|
// pubsub
|
||||||
|
mux.HandleFunc("/v1/pubsub/ws", g.pubsubWebsocketHandler)
|
||||||
|
mux.HandleFunc("/v1/pubsub/publish", g.pubsubPublishHandler)
|
||||||
|
mux.HandleFunc("/v1/pubsub/topics", g.pubsubTopicsHandler)
|
||||||
|
|
||||||
|
return g.withMiddleware(mux)
|
||||||
|
}
|
87
pkg/gateway/status_handlers.go
Normal file
87
pkg/gateway/status_handlers.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/client"
|
||||||
|
"git.debros.io/DeBros/network/pkg/logging"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build info (set via -ldflags at build time; defaults for dev)
|
||||||
|
var (
|
||||||
|
BuildVersion = "dev"
|
||||||
|
BuildCommit = ""
|
||||||
|
BuildTime = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// healthResponse is the JSON structure used by healthHandler
|
||||||
|
type healthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
Uptime string `json:"uptime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
server := healthResponse{
|
||||||
|
Status: "ok",
|
||||||
|
StartedAt: g.startedAt,
|
||||||
|
Uptime: time.Since(g.startedAt).String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientHealth *client.HealthStatus
|
||||||
|
if g.client != nil {
|
||||||
|
if h, err := g.client.Health(); err == nil {
|
||||||
|
clientHealth = h
|
||||||
|
} else {
|
||||||
|
g.logger.ComponentWarn(logging.ComponentClient, "failed to fetch client health", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Server healthResponse `json:"server"`
|
||||||
|
Client *client.HealthStatus `json:"client"`
|
||||||
|
}{
|
||||||
|
Status: "ok",
|
||||||
|
Server: server,
|
||||||
|
Client: clientHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusHandler aggregates server uptime and network status
|
||||||
|
func (g *Gateway) statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
status, err := g.client.Network().GetStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"server": map[string]any{
|
||||||
|
"started_at": g.startedAt,
|
||||||
|
"uptime": time.Since(g.startedAt).String(),
|
||||||
|
},
|
||||||
|
"network": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionHandler returns gateway build/runtime information
|
||||||
|
func (g *Gateway) versionHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"version": BuildVersion,
|
||||||
|
"commit": BuildCommit,
|
||||||
|
"build_time": BuildTime,
|
||||||
|
"started_at": g.startedAt,
|
||||||
|
"uptime": time.Since(g.startedAt).String(),
|
||||||
|
})
|
||||||
|
}
|
279
pkg/gateway/storage_handlers.go
Normal file
279
pkg/gateway/storage_handlers.go
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.debros.io/DeBros/network/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (g *Gateway) storageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'key' query parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
val, err := g.client.Storage().Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(val)
|
||||||
|
return
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
defer r.Body.Close()
|
||||||
|
b, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.Storage().Put(ctx, key, b); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"key": key,
|
||||||
|
"size": len(b),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
case http.MethodOptions:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) networkStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
status, err := g.client.Network().GetStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) networkPeersHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
peers, err := g.client.Network().GetPeers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, peers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) storageGetHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'key'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.validateNamespaceParam(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val, err := g.client.Storage().Get(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) storagePutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'key'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.validateNamespaceParam(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
b, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "failed to read body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.Storage().Put(r.Context(), key, b); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{"status": "ok", "key": key, "size": len(b)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) storageDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.validateNamespaceParam(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
var body struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err == nil {
|
||||||
|
key = body.Key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'key'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.Storage().Delete(r.Context(), key); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "key": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) storageListHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.validateNamespaceParam(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := r.URL.Query().Get("prefix")
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if n, err := strconv.Atoi(limitStr); err == nil && n > 0 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, err := g.client.Storage().List(r.Context(), prefix, limit)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"keys": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) storageExistsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !g.validateNamespaceParam(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "namespace mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "missing 'key'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exists, err := g.client.Storage().Exists(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"exists": exists})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) networkConnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Multiaddr string `json:"multiaddr"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Multiaddr == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body: expected {multiaddr}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.Network().ConnectToPeer(r.Context(), body.Multiaddr); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) networkDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if g.client == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
PeerID string `json:"peer_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.PeerID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body: expected {peer_id}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.client.Network().DisconnectFromPeer(r.Context(), body.PeerID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) validateNamespaceParam(r *http.Request) bool {
|
||||||
|
qns := r.URL.Query().Get("namespace")
|
||||||
|
if qns == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if v := r.Context().Value(storage.CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s == qns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no namespace in context, disallow explicit namespace param
|
||||||
|
return false
|
||||||
|
}
|
16
pkg/pubsub/context.go
Normal file
16
pkg/pubsub/context.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package pubsub
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Context utilities for namespace override
|
||||||
|
// Keep type unexported and expose the key as exported constant to avoid collisions
|
||||||
|
// while still allowing other packages to use the exact key value.
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
// CtxKeyNamespaceOverride is the context key used to override namespace per pubsub call
|
||||||
|
const CtxKeyNamespaceOverride ctxKey = "pubsub_ns_override"
|
||||||
|
|
||||||
|
// WithNamespace returns a new context that carries a pubsub namespace override
|
||||||
|
func WithNamespace(ctx context.Context, ns string) context.Context {
|
||||||
|
return context.WithValue(ctx, CtxKeyNamespaceOverride, ns)
|
||||||
|
}
|
@ -11,7 +11,15 @@ func (m *Manager) Publish(ctx context.Context, topic string, data []byte) error
|
|||||||
return fmt.Errorf("pubsub not initialized")
|
return fmt.Errorf("pubsub not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
namespacedTopic := fmt.Sprintf("%s.%s", m.namespace, topic)
|
// Determine namespace (allow per-call override via context)
|
||||||
|
ns := m.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespacedTopic := fmt.Sprintf("%s.%s", ns, topic)
|
||||||
|
|
||||||
// Get or create topic
|
// Get or create topic
|
||||||
libp2pTopic, err := m.getOrCreateTopic(namespacedTopic)
|
libp2pTopic, err := m.getOrCreateTopic(namespacedTopic)
|
||||||
|
@ -13,7 +13,14 @@ func (m *Manager) Subscribe(ctx context.Context, topic string, handler MessageHa
|
|||||||
return fmt.Errorf("pubsub not initialized")
|
return fmt.Errorf("pubsub not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
namespacedTopic := fmt.Sprintf("%s.%s", m.namespace, topic)
|
// Determine namespace (allow per-call override via context)
|
||||||
|
ns := m.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespacedTopic := fmt.Sprintf("%s.%s", ns, topic)
|
||||||
|
|
||||||
// Check if already subscribed
|
// Check if already subscribed
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@ -86,7 +93,14 @@ func (m *Manager) Unsubscribe(ctx context.Context, topic string) error {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
namespacedTopic := fmt.Sprintf("%s.%s", m.namespace, topic)
|
// Determine namespace (allow per-call override via context)
|
||||||
|
ns := m.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespacedTopic := fmt.Sprintf("%s.%s", ns, topic)
|
||||||
|
|
||||||
if subscription, exists := m.subscriptions[namespacedTopic]; exists {
|
if subscription, exists := m.subscriptions[namespacedTopic]; exists {
|
||||||
// Cancel the subscription context to stop the message handler goroutine
|
// Cancel the subscription context to stop the message handler goroutine
|
||||||
@ -103,7 +117,14 @@ func (m *Manager) ListTopics(ctx context.Context) ([]string, error) {
|
|||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
var topics []string
|
var topics []string
|
||||||
prefix := m.namespace + "."
|
// Determine namespace (allow per-call override via context)
|
||||||
|
ns := m.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix := ns + "."
|
||||||
|
|
||||||
for topic := range m.subscriptions {
|
for topic := range m.subscriptions {
|
||||||
if len(topic) > len(prefix) && topic[:len(prefix)] == prefix {
|
if len(topic) > len(prefix) && topic[:len(prefix)] == prefix {
|
||||||
|
@ -19,6 +19,17 @@ type Client struct {
|
|||||||
namespace string
|
namespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Context utilities for namespace override
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
// CtxKeyNamespaceOverride is the context key used to override namespace per request
|
||||||
|
const CtxKeyNamespaceOverride ctxKey = "storage_ns_override"
|
||||||
|
|
||||||
|
// WithNamespace returns a new context that carries a storage namespace override
|
||||||
|
func WithNamespace(ctx context.Context, ns string) context.Context {
|
||||||
|
return context.WithValue(ctx, CtxKeyNamespaceOverride, ns)
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient creates a new storage client
|
// NewClient creates a new storage client
|
||||||
func NewClient(h host.Host, namespace string, logger *zap.Logger) *Client {
|
func NewClient(h host.Host, namespace string, logger *zap.Logger) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
@ -30,11 +41,17 @@ func NewClient(h host.Host, namespace string, logger *zap.Logger) *Client {
|
|||||||
|
|
||||||
// Put stores a key-value pair in the distributed storage
|
// Put stores a key-value pair in the distributed storage
|
||||||
func (c *Client) Put(ctx context.Context, key string, value []byte) error {
|
func (c *Client) Put(ctx context.Context, key string, value []byte) error {
|
||||||
|
ns := c.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
request := &StorageRequest{
|
request := &StorageRequest{
|
||||||
Type: MessageTypePut,
|
Type: MessageTypePut,
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: value,
|
||||||
Namespace: c.namespace,
|
Namespace: ns,
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.sendRequest(ctx, request)
|
return c.sendRequest(ctx, request)
|
||||||
@ -42,10 +59,16 @@ func (c *Client) Put(ctx context.Context, key string, value []byte) error {
|
|||||||
|
|
||||||
// Get retrieves a value by key from the distributed storage
|
// Get retrieves a value by key from the distributed storage
|
||||||
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
ns := c.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
request := &StorageRequest{
|
request := &StorageRequest{
|
||||||
Type: MessageTypeGet,
|
Type: MessageTypeGet,
|
||||||
Key: key,
|
Key: key,
|
||||||
Namespace: c.namespace,
|
Namespace: ns,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := c.sendRequestWithResponse(ctx, request)
|
response, err := c.sendRequestWithResponse(ctx, request)
|
||||||
@ -62,10 +85,16 @@ func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
|||||||
|
|
||||||
// Delete removes a key from the distributed storage
|
// Delete removes a key from the distributed storage
|
||||||
func (c *Client) Delete(ctx context.Context, key string) error {
|
func (c *Client) Delete(ctx context.Context, key string) error {
|
||||||
|
ns := c.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
request := &StorageRequest{
|
request := &StorageRequest{
|
||||||
Type: MessageTypeDelete,
|
Type: MessageTypeDelete,
|
||||||
Key: key,
|
Key: key,
|
||||||
Namespace: c.namespace,
|
Namespace: ns,
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.sendRequest(ctx, request)
|
return c.sendRequest(ctx, request)
|
||||||
@ -73,11 +102,17 @@ func (c *Client) Delete(ctx context.Context, key string) error {
|
|||||||
|
|
||||||
// List returns keys with a given prefix
|
// List returns keys with a given prefix
|
||||||
func (c *Client) List(ctx context.Context, prefix string, limit int) ([]string, error) {
|
func (c *Client) List(ctx context.Context, prefix string, limit int) ([]string, error) {
|
||||||
|
ns := c.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
request := &StorageRequest{
|
request := &StorageRequest{
|
||||||
Type: MessageTypeList,
|
Type: MessageTypeList,
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Namespace: c.namespace,
|
Namespace: ns,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := c.sendRequestWithResponse(ctx, request)
|
response, err := c.sendRequestWithResponse(ctx, request)
|
||||||
@ -94,10 +129,16 @@ func (c *Client) List(ctx context.Context, prefix string, limit int) ([]string,
|
|||||||
|
|
||||||
// Exists checks if a key exists in the distributed storage
|
// Exists checks if a key exists in the distributed storage
|
||||||
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
|
func (c *Client) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
ns := c.namespace
|
||||||
|
if v := ctx.Value(CtxKeyNamespaceOverride); v != nil {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
request := &StorageRequest{
|
request := &StorageRequest{
|
||||||
Type: MessageTypeExists,
|
Type: MessageTypeExists,
|
||||||
Key: key,
|
Key: key,
|
||||||
Namespace: c.namespace,
|
Namespace: ns,
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := c.sendRequestWithResponse(ctx, request)
|
response, err := c.sendRequestWithResponse(ctx, request)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user