Plugin System
Add any capability to your Sertone control center — rate limiting, analytics, SLA bonds, custom fees, compliance filters — without touching the core. Write in any language. Deploy as a sidecar. Zero lock-in.
How it works
A plugin is any HTTP service that implements three endpoints. You register its URL in your control center panel. The control center calls your plugin on each request lifecycle event.
Plugins can be installed by the provider (to control who calls their services), by the consumer (to control which services they call), or both. A plugin can charge an additional fee on each request — that fee flows directly between the control centers, independent of the main 5% IP royalty settlement.
Hook API Reference
GET /sertone/info
Called when the plugin is first registered and periodically to verify it is alive. Returns plugin metadata.
| Field | Type | Description |
|---|---|---|
name | string | Human-readable plugin name |
version | string | Semantic version, e.g. "1.0.0" |
description | string | One-sentence description |
author | string | Plugin author (optional, can be anonymous) |
fee_model | "provider" | "consumer" | "none" | Who pays the plugin fee |
{
"name": "My Rate Limiter",
"version": "1.0.0",
"description": "Limits each consumer to 100 requests per hour",
"author": "anonymous",
"fee_model": "none"
}
POST /sertone/pre-request
Called before every request is forwarded. Your plugin decides whether to allow it and optionally adds a fee.
Request body:
| Field | Type | Description |
|---|---|---|
request_id | string | Unique ID for this request (use for deduplication) |
api_id | string | Public ID of the API being called |
consumer_wallet_hash | string | SHA-256 of the consumer's wallet address (privacy-preserving identifier — same consumer always has same hash) |
endpoint_path | string | The specific endpoint being called, e.g. /v1/data |
timestamp | number | Unix timestamp in milliseconds |
Response:
| Field | Type | Required | Description |
|---|---|---|---|
allow | boolean | Yes | If false, the request is rejected with HTTP 402 |
reason | string | No | Reason shown to consumer when allow is false |
additional_fee_usdc | string | No | Extra USDC fee as a decimal string, e.g. "0.00010" |
fee_destination | string | If fee > 0 | EVM wallet address to receive the fee |
// Allow with no fee
{ "allow": true }
// Block with reason
{ "allow": false, "reason": "Rate limit exceeded. Resets in 42 seconds." }
// Allow and charge 0.0001 USDC
{
"allow": true,
"additional_fee_usdc": "0.00010",
"fee_destination": "0xYourPluginWalletAddress"
}
POST /sertone/post-response
Called after every response is received. Use this to collect quality signals, update analytics, or trigger alerts. Your response is advisory — it does not block or modify the response.
Request body:
| Field | Type | Description |
|---|---|---|
request_id | string | Same ID from pre-request |
api_id | string | Public API ID |
latency_ms | number | Round-trip latency in milliseconds |
status_code | number | HTTP status code returned by the provider |
settled_usdc | string | Amount settled to provider, e.g. "0.00095" |
timestamp | number | Unix timestamp in milliseconds |
Response:
| Field | Type | Description |
|---|---|---|
quality_signal | -1 | 0 | 1 | Your quality assessment: bad / neutral / good. Fed into the catalog quality score for this API. |
{ "quality_signal": 1 } // good
{ "quality_signal": 0 } // neutral
{ "quality_signal": -1 } // bad (e.g. latency > 2000ms or status 5xx)
Quickstart
The fastest path to a working plugin is three steps:
1. Write the plugin
Copy any of the examples below. The minimum viable plugin is about 40 lines of code in any language.
2. Run it as a sidecar
Run your plugin on the same host as your control center. The control center calls it on localhost:PORT — it never needs to be publicly accessible.
# docker-compose.yml — add your plugin as a sidecar
services:
sertone:
image: xbtest33/demo22:latest
# ... your existing sertone config ...
my-plugin:
image: my-plugin:latest
ports:
- "127.0.0.1:4001:4001" # only localhost, not public
restart: unless-stopped
3. Register in your control center
Go to Settings → Plugins in your admin panel. Enter the plugin URL: http://localhost:4001. The control center pings /sertone/info to verify the connection, then activates all hooks.
Node.js Examples
Hello World (Node.js)
The minimal plugin. Allows all requests, reports all responses as good quality. No dependencies beyond Node.js built-ins.
// sertone-plugin-hello — minimal Node.js plugin
// No dependencies. Allows all requests, good quality on all responses.
// Run: node server.js
const http = require('http');
const PORT = process.env.PORT || 4001;
const routes = {
'GET /sertone/info': (req, res) => {
res.json({
name: 'Hello World Plugin',
version: '1.0.0',
description: 'Allows all requests and reports all responses as good quality.',
author: 'anonymous',
fee_model: 'none',
});
},
'POST /sertone/pre-request': (req, res) => {
// Read and discard body (required to keep the connection clean)
req.resume();
req.on('end', () => res.json({ allow: true }));
},
'POST /sertone/post-response': (req, res) => {
req.resume();
req.on('end', () => res.json({ quality_signal: 1 }));
},
};
// Minimal HTTP server with JSON helpers
const server = http.createServer((req, res) => {
const key = `${req.method} ${req.url}`;
res.json = (data) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
if (routes[key]) {
routes[key](req, res);
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Hello World plugin running on port ${PORT}`);
});
FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 4001
CMD ["node", "server.js"]
services:
sertone-plugin-hello:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
environment:
- PORT=4001
Rate Limiter (Node.js)
Limits each consumer to a configurable number of requests per time window. Tracks consumers by their wallet hash. No database required — in-memory sliding window.
// sertone-plugin-ratelimiter — sliding window rate limiter
// No dependencies. Limits each consumer by wallet hash.
// Config via environment variables.
const http = require('http');
const PORT = parseInt(process.env.PORT || '4001');
const MAX_REQUESTS = parseInt(process.env.MAX_REQUESTS || '100'); // per window
const WINDOW_MS = parseInt(process.env.WINDOW_MS || '3600000'); // 1 hour default
// Map: consumerHash -> array of request timestamps
const windows = new Map();
function getWindowCount(hash) {
const now = Date.now();
const cutoff = now - WINDOW_MS;
const times = (windows.get(hash) || []).filter(t => t > cutoff);
times.push(now);
windows.set(hash, times);
return times.length; // includes the current request
}
function timeUntilReset(hash) {
const times = windows.get(hash) || [];
if (times.length === 0) return 0;
const oldest = Math.min(...times);
return Math.ceil((oldest + WINDOW_MS - Date.now()) / 1000);
}
// Cleanup old entries every 10 minutes
setInterval(() => {
const cutoff = Date.now() - WINDOW_MS;
for (const [hash, times] of windows) {
const filtered = times.filter(t => t > cutoff);
if (filtered.length === 0) windows.delete(hash);
else windows.set(hash, filtered);
}
}, 10 * 60 * 1000);
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', chunk => data += chunk);
req.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve({}); }
});
});
}
const server = http.createServer(async (req, res) => {
res.json = (data, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
if (req.method === 'GET' && req.url === '/sertone/info') {
return res.json({
name: 'Rate Limiter',
version: '1.0.0',
description: `Limits each consumer to ${MAX_REQUESTS} requests per ${WINDOW_MS / 60000} minutes.`,
author: 'anonymous',
fee_model: 'none',
});
}
if (req.method === 'POST' && req.url === '/sertone/pre-request') {
const body = await readBody(req);
const hash = body.consumer_wallet_hash || 'unknown';
const count = getWindowCount(hash);
if (count > MAX_REQUESTS) {
const resetIn = timeUntilReset(hash);
return res.json({
allow: false,
reason: `Rate limit exceeded (${MAX_REQUESTS} requests per ${WINDOW_MS / 60000} min). Resets in ${resetIn}s.`,
});
}
return res.json({ allow: true });
}
if (req.method === 'POST' && req.url === '/sertone/post-response') {
const body = await readBody(req);
const signal = body.status_code >= 500 ? -1 : (body.latency_ms > 3000 ? 0 : 1);
return res.json({ quality_signal: signal });
}
res.writeHead(404);
res.end('Not found');
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Rate Limiter plugin: max=${MAX_REQUESTS} per ${WINDOW_MS / 60000}min on port ${PORT}`);
});
FROM node:22-alpine
WORKDIR /app
COPY server.js .
EXPOSE 4001
CMD ["node", "server.js"]
services:
sertone-plugin-ratelimiter:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
environment:
- PORT=4001
- MAX_REQUESTS=100 # requests per window
- WINDOW_MS=3600000 # window size in ms (3600000 = 1 hour)
Python Examples
Hello World (Python)
Same minimal plugin in Python. Uses only the standard library — no pip install needed.
# sertone-plugin-hello — minimal Python plugin
# No dependencies. Standard library only.
# Run: python3 plugin.py
import os
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(os.environ.get('PORT', 4001))
class PluginHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass # Silence default access log
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
raw = self.rfile.read(length) if length > 0 else b'{}'
try:
return json.loads(raw)
except Exception:
return {}
def _respond(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == '/sertone/info':
self._respond({
'name': 'Hello World Plugin (Python)',
'version': '1.0.0',
'description': 'Allows all requests and reports good quality.',
'author': 'anonymous',
'fee_model': 'none',
})
else:
self.send_error(404)
def do_POST(self):
self._read_body() # consume body
if self.path == '/sertone/pre-request':
self._respond({'allow': True})
elif self.path == '/sertone/post-response':
self._respond({'quality_signal': 1})
else:
self.send_error(404)
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
print(f'Hello World plugin (Python) running on port {PORT}')
server.serve_forever()
FROM python:3.12-alpine
WORKDIR /app
COPY plugin.py .
EXPOSE 4001
CMD ["python3", "plugin.py"]
services:
sertone-plugin-hello-py:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
environment:
- PORT=4001
Request Logger (Python)
Logs every request and response to JSONL files on disk. Useful for debugging, auditing, and building your own analytics. Rotates log files daily.
# sertone-plugin-logger — JSONL request logger with daily rotation
# No dependencies. Standard library only.
# Logs to /app/logs/YYYY-MM-DD.jsonl (mount as a volume to persist)
import os
import json
import time
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
PORT = int(os.environ.get('PORT', 4001))
LOG_DIR = Path(os.environ.get('LOG_DIR', '/app/logs'))
LOG_DIR.mkdir(parents=True, exist_ok=True)
_lock = threading.Lock()
def log_event(event_type, data):
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
entry = {'_type': event_type, '_ts': int(time.time() * 1000), **data}
with _lock:
with open(LOG_DIR / f'{today}.jsonl', 'a') as f:
f.write(json.dumps(entry) + '\n')
class PluginHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
raw = self.rfile.read(length) if length > 0 else b'{}'
try:
return json.loads(raw)
except Exception:
return {}
def _respond(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == '/sertone/info':
logs = sorted(LOG_DIR.glob('*.jsonl'))
self._respond({
'name': 'Request Logger',
'version': '1.0.0',
'description': 'Logs all requests and responses to JSONL files.',
'author': 'anonymous',
'fee_model': 'none',
'log_files': [f.name for f in logs[-7:]],
})
elif self.path.startswith('/logs/'):
filename = self.path[6:]
filepath = LOG_DIR / filename
if filepath.exists() and filepath.parent == LOG_DIR:
content = filepath.read_bytes()
self.send_response(200)
self.send_header('Content-Type', 'application/x-ndjson')
self.send_header('Content-Length', str(len(content)))
self.end_headers()
self.wfile.write(content)
else:
self.send_error(404)
else:
self.send_error(404)
def do_POST(self):
body = self._read_body()
if self.path == '/sertone/pre-request':
log_event('request', body)
self._respond({'allow': True})
elif self.path == '/sertone/post-response':
log_event('response', body)
status = body.get('status_code', 200)
latency = body.get('latency_ms', 0)
signal = -1 if status >= 500 else (0 if latency > 3000 else 1)
self._respond({'quality_signal': signal})
else:
self.send_error(404)
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
print(f'Logger plugin running on port {PORT}, logs in {LOG_DIR}')
server.serve_forever()
FROM python:3.12-alpine
WORKDIR /app
COPY plugin.py .
RUN mkdir -p /app/logs
EXPOSE 4001
CMD ["python3", "plugin.py"]
services:
sertone-plugin-logger:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
volumes:
- ./logs:/app/logs # persists logs on the host
environment:
- PORT=4001
- LOG_DIR=/app/logs
Go Examples
Hello World (Go)
Zero dependencies. Compiles to a single static binary under 6 MB. Ideal for resource-constrained environments like Raspberry Pi.
// sertone-plugin-hello — minimal Go plugin
// Zero dependencies. Single binary. ~5 MB.
// Build: go build -o plugin . && ./plugin
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "4001"
}
http.HandleFunc("/sertone/info", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
respond(w, map[string]any{
"name": "Hello World Plugin (Go)",
"version": "1.0.0",
"description": "Allows all requests and reports good quality.",
"author": "anonymous",
"fee_model": "none",
})
})
http.HandleFunc("/sertone/pre-request", func(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body) // consume body
respond(w, map[string]any{"allow": true})
})
http.HandleFunc("/sertone/post-response", func(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
respond(w, map[string]any{"quality_signal": 1})
})
log.Printf("Hello World plugin (Go) running on port %s", port)
log.Fatal(http.ListenAndServe("0.0.0.0:"+port, nil))
}
func respond(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
module sertone-plugin-hello
go 1.22
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -ldflags="-s -w" -o plugin .
FROM scratch
COPY --from=builder /app/plugin /plugin
EXPOSE 4001
ENTRYPOINT ["/plugin"]
services:
sertone-plugin-hello-go:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
environment:
- PORT=4001
Reference Plugins
These plugins are open-source, ready to run, and solve real problems. Each is published with full source code.
Providers lock a USDC deposit as a public quality commitment. The bond is slashed and paid to consumers if the provider's quality score drops below a configurable threshold. Fully on-chain, no third-party custodian. Source published — run your own instance or use a community-operated one.
// SLA Bond Plugin — provider-side quality commitment
// Tracks quality score per API over a rolling window.
// On threshold breach, signals consumers to claim from the bond contract.
// Deploy the SLABond.sol contract separately and set CONTRACT_ADDRESS.
const http = require('http');
const { ethers } = require('ethers');
const PORT = parseInt(process.env.PORT || '4001');
const QUALITY_THRESHOLD = parseFloat(process.env.QUALITY_THRESHOLD || '80'); // 0-100
const WINDOW_HOURS = parseInt(process.env.WINDOW_HOURS || '168'); // 7 days
const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS || '';
const RPC_URL = process.env.RPC_URL || 'https://mainnet.base.org';
// Rolling window quality store: api_id -> [{ts, signal}]
const signals = new Map();
function addSignal(apiId, signal) {
const cutoff = Date.now() - WINDOW_HOURS * 3600 * 1000;
const list = (signals.get(apiId) || []).filter(s => s.ts > cutoff);
list.push({ ts: Date.now(), signal });
signals.set(apiId, list);
return list;
}
function qualityScore(apiId) {
const list = signals.get(apiId) || [];
if (list.length === 0) return 100;
const sum = list.reduce((acc, s) => acc + (s.signal === 1 ? 100 : s.signal === 0 ? 50 : 0), 0);
return sum / list.length;
}
function readBody(req) {
return new Promise(resolve => {
let data = '';
req.on('data', c => data += c);
req.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve({}); }});
});
}
const server = http.createServer(async (req, res) => {
res.json = (data, status = 200) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
if (req.method === 'GET' && req.url === '/sertone/info') {
return res.json({
name: 'SLA Bond',
version: '1.0.0',
description: `Quality commitment bond. Threshold: ${QUALITY_THRESHOLD}. Window: ${WINDOW_HOURS}h.`,
author: 'community',
fee_model: 'provider',
contract: CONTRACT_ADDRESS || 'not configured',
});
}
if (req.method === 'GET' && req.url.startsWith('/quality/')) {
const apiId = req.url.slice(9);
return res.json({ api_id: apiId, quality: qualityScore(apiId), threshold: QUALITY_THRESHOLD });
}
if (req.method === 'POST' && req.url === '/sertone/pre-request') {
const body = await readBody(req);
// Check current quality — if critically low, pause the API
const score = qualityScore(body.api_id);
if (score < QUALITY_THRESHOLD * 0.5) {
return res.json({
allow: false,
reason: `Provider quality score ${score.toFixed(0)}/100 is critically low. Service temporarily paused for consumer protection.`,
});
}
return res.json({ allow: true });
}
if (req.method === 'POST' && req.url === '/sertone/post-response') {
const body = await readBody(req);
const status = body.status_code || 200;
const latency = body.latency_ms || 0;
let signal = 1;
if (status >= 500) signal = -1;
else if (status >= 400 || latency > 5000) signal = 0;
else if (latency > 2000) signal = 0;
const list = addSignal(body.api_id, signal);
const score = qualityScore(body.api_id);
// If quality drops below threshold, log for operator review
if (score < QUALITY_THRESHOLD && list.length >= 10) {
console.warn(`Quality alert: api=${body.api_id} score=${score.toFixed(1)} threshold=${QUALITY_THRESHOLD}`);
}
return res.json({ quality_signal: signal });
}
res.writeHead(404);
res.end('Not found');
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`SLA Bond plugin running on port ${PORT}, threshold=${QUALITY_THRESHOLD}`);
});
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title SLABond — on-chain quality commitment deposit
/// @notice Providers deposit USDC. If quality drops, consumers claim compensation.
/// No admin key. No upgradeable proxy. Fully autonomous.
contract SLABond {
IERC20 public immutable usdc;
struct Bond {
uint256 amount; // USDC deposited (6 decimals)
uint256 depositedAt;
bool active;
}
// provider wallet => api_id => bond
mapping(address => mapping(bytes32 => Bond)) public bonds;
// quality oracle: set by the plugin server calling this contract
address public immutable oracle;
event BondDeposited(address indexed provider, bytes32 indexed apiId, uint256 amount);
event BondClaimed(address indexed consumer, bytes32 indexed apiId, uint256 amount);
event BondWithdrawn(address indexed provider, bytes32 indexed apiId, uint256 amount);
constructor(address _usdc, address _oracle) {
usdc = IERC20(_usdc);
oracle = _oracle;
}
/// @notice Provider deposits bond for a specific API
function deposit(bytes32 apiId, uint256 amount) external {
require(amount > 0, "Amount must be positive");
usdc.transferFrom(msg.sender, address(this), amount);
bonds[msg.sender][apiId].amount += amount;
bonds[msg.sender][apiId].depositedAt = block.timestamp;
bonds[msg.sender][apiId].active = true;
emit BondDeposited(msg.sender, apiId, amount);
}
/// @notice Oracle (plugin server) triggers consumer compensation on SLA breach
/// @dev Only the oracle address can call this. Oracle is the plugin server.
function claimBreach(
address provider,
bytes32 apiId,
address consumer,
uint256 compensation
) external {
require(msg.sender == oracle, "Only oracle");
Bond storage b = bonds[provider][apiId];
require(b.active, "No active bond");
require(b.amount >= compensation, "Insufficient bond");
b.amount -= compensation;
if (b.amount == 0) b.active = false;
usdc.transfer(consumer, compensation);
emit BondClaimed(consumer, apiId, compensation);
}
/// @notice Provider withdraws remaining bond (voluntary exit)
function withdraw(bytes32 apiId) external {
Bond storage b = bonds[msg.sender][apiId];
require(b.amount > 0, "Nothing to withdraw");
uint256 amount = b.amount;
b.amount = 0;
b.active = false;
usdc.transfer(msg.sender, amount);
emit BondWithdrawn(msg.sender, apiId, amount);
}
/// @notice View bond for a provider + api
function getBond(address provider, bytes32 apiId)
external view returns (uint256 amount, bool active) {
Bond storage b = bonds[provider][apiId];
return (b.amount, b.active);
}
}
services:
sertone-plugin-sla-bond:
build: .
ports:
- "127.0.0.1:4001:4001"
restart: unless-stopped
environment:
- PORT=4001
- QUALITY_THRESHOLD=80 # minimum acceptable quality score (0-100)
- WINDOW_HOURS=168 # rolling window (168 = 7 days)
- CONTRACT_ADDRESS=0x... # deployed SLABond.sol contract address
- RPC_URL=https://mainnet.base.org
Blocks or allows consumers from specific regions based on the mesh node origin. Useful for providers who need to comply with export controls or licensing restrictions. Because Sertone is anonymous by design, this operates on mesh topology signals, not IP addresses.
# Geographic Firewall Plugin — allowlist/denylist by wallet hash prefix
# In a fully anonymous network, "geography" is approximated by
# the wallet addresses registered in specific regional catalogs.
# Configure ALLOWED_PREFIXES (hex prefix of consumer wallet hash)
# or BLOCKED_PREFIXES to restrict access.
# This is a soft control — it filters, not guarantees.
import os
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(os.environ.get('PORT', 4001))
ALLOWED_PREFIXES = [p for p in os.environ.get('ALLOWED_PREFIXES', '').split(',') if p]
BLOCKED_PREFIXES = [p for p in os.environ.get('BLOCKED_PREFIXES', '').split(',') if p]
def is_allowed(wallet_hash: str) -> tuple[bool, str]:
h = wallet_hash.lower()
if BLOCKED_PREFIXES:
for prefix in BLOCKED_PREFIXES:
if h.startswith(prefix.lower()):
return False, f'Consumer blocked by geographic policy.'
if ALLOWED_PREFIXES:
for prefix in ALLOWED_PREFIXES:
if h.startswith(prefix.lower()):
return True, ''
return False, 'Consumer not in allowed region.'
return True, ''
class PluginHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): pass
def _read_body(self):
length = int(self.headers.get('Content-Length', 0))
raw = self.rfile.read(length) if length > 0 else b'{}'
try: return json.loads(raw)
except: return {}
def _respond(self, data, status=200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == '/sertone/info':
self._respond({
'name': 'Geographic Firewall',
'version': '1.0.0',
'description': 'Allows or blocks consumers by wallet hash prefix.',
'author': 'anonymous',
'fee_model': 'none',
'allowed_prefixes': ALLOWED_PREFIXES or ['(all)'],
'blocked_prefixes': BLOCKED_PREFIXES or ['(none)'],
})
else:
self.send_error(404)
def do_POST(self):
body = self._read_body()
if self.path == '/sertone/pre-request':
h = body.get('consumer_wallet_hash', '')
allowed, reason = is_allowed(h)
resp = {'allow': allowed}
if not allowed: resp['reason'] = reason
self._respond(resp)
elif self.path == '/sertone/post-response':
self._respond({'quality_signal': 1})
else:
self.send_error(404)
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', PORT), PluginHandler)
print(f'Geographic Firewall on port {PORT}')
if ALLOWED_PREFIXES: print(f' Allowed prefixes: {ALLOWED_PREFIXES}')
if BLOCKED_PREFIXES: print(f' Blocked prefixes: {BLOCKED_PREFIXES}')
server.serve_forever()
Security notes
- Plugins run locally. By default, only
localhostURLs are accepted in the plugin registration. A plugin must run on the same host as your control center. Remote plugin URLs are supported but require explicitly enabling in settings. - Plugin errors never block traffic. A crashed, slow, or misbehaving plugin is silently bypassed. Your services remain available.
- Plugins do not see request payloads. The hook only receives the metadata (api_id, consumer hash, endpoint path, latency, status code). The actual request and response content stays end-to-end encrypted.
- The consumer wallet hash is privacy-preserving. It is a SHA-256 of the consumer's wallet address, not the address itself. The same consumer always produces the same hash — useful for rate limiting — but the hash cannot be reversed to recover the wallet address.
- Plugins can charge fees, but the fee destination is fully in your control. Only install plugins from sources you trust.