Compare commits
3 commits
84a804c57a
...
4e78557158
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e78557158 | ||
|
|
fc5fa4e16d | ||
|
|
83cbe608a0 |
14 changed files with 2562 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ agent_gateway.db
|
|||
.env
|
||||
mcp-bridge/node_modules
|
||||
node_modules/
|
||||
data/
|
||||
|
|
|
|||
61
admin-ui/README.md
Normal file
61
admin-ui/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Admin UI
|
||||
|
||||
A web-based dashboard for monitoring the MCP Hub in real-time.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Get Your OBSERVE_SECRET
|
||||
|
||||
The `OBSERVE_SECRET` is stored in `/workspace/.env` on the hub server. Extract it:
|
||||
|
||||
```bash
|
||||
# On the hub server:
|
||||
grep OBSERVE_SECRET /workspace/.env
|
||||
```
|
||||
|
||||
Copy the secret value (it's a long hex string).
|
||||
|
||||
### 2. Open the UI
|
||||
|
||||
#### Option A: Via https (production)
|
||||
```
|
||||
https://mcp.arik.work/admin-ui/
|
||||
```
|
||||
|
||||
#### Option B: Local development with file:// (offline)
|
||||
```bash
|
||||
# Simply open the file in your browser:
|
||||
file:///workspace/admin-ui/index.html
|
||||
```
|
||||
|
||||
#### Option C: Local development with HTTP server
|
||||
```bash
|
||||
cd /workspace/admin-ui
|
||||
python3 -m http.server 8080
|
||||
# Then open http://localhost:8080
|
||||
```
|
||||
|
||||
### 3. Connect to the Hub
|
||||
|
||||
In the UI, enter:
|
||||
- **Hub URL**: `wss://mcp.arik.work/ws/observe` (production) or `ws://localhost:3000/ws/observe` (local dev)
|
||||
- **OBSERVE_SECRET**: Paste the value from step 1
|
||||
|
||||
The status light will turn **green** when connected.
|
||||
|
||||
### 4. Monitor
|
||||
|
||||
- **Backends**: View connected MCP backends
|
||||
- **Logs**: Real-time event stream from the hub
|
||||
- **Status**: Connection indicator at the top
|
||||
|
||||
## Adding More Backends
|
||||
|
||||
To connect additional backends to the hub, edit `/workspace/.env` on the hub server:
|
||||
|
||||
```env
|
||||
OBSERVE_SECRET=...
|
||||
# Add new backends via environment config or the hub's register endpoint
|
||||
```
|
||||
|
||||
Then restart the hub to pick up new backends.
|
||||
788
admin-ui/index.html
Normal file
788
admin-ui/index.html
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCP Hub Admin</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0d1117;
|
||||
color: #e6edf3;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background-color: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: #3fb950;
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background-color: #d29922;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background-color: #f85149;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.hub-url-input {
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
color: #e6edf3;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.secret-input {
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
color: #e6edf3;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: #1f6feb;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #388bfd;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background-color: #21262d;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background-color: #30363d;
|
||||
}
|
||||
|
||||
.content-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background-color: #0d1117;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.backends-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.backend-card {
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.backend-card.disconnected {
|
||||
opacity: 0.5;
|
||||
border-color: #f85149;
|
||||
}
|
||||
|
||||
.backend-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.backend-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.backend-status-dot.connected {
|
||||
background-color: #3fb950;
|
||||
}
|
||||
|
||||
.backend-status-dot.disconnected {
|
||||
background-color: #f85149;
|
||||
}
|
||||
|
||||
.backend-info {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.backend-counter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.log-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-filter {
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
color: #e6edf3;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.log-lines {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #21262d;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #8b949e;
|
||||
min-width: 120px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
min-width: 60px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-level.info {
|
||||
background-color: #0d47a1;
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.log-level.warn {
|
||||
background-color: #663c00;
|
||||
color: #d29922;
|
||||
}
|
||||
|
||||
.log-level.error {
|
||||
background-color: #490202;
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.inspector-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspector-table {
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inspector-row {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inspector-row:hover {
|
||||
background-color: #21262d;
|
||||
}
|
||||
|
||||
.inspector-row.expanded {
|
||||
background-color: #21262d;
|
||||
}
|
||||
|
||||
.inspector-column {
|
||||
flex: 0 0 auto;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.inspector-direction {
|
||||
color: #79c0ff;
|
||||
}
|
||||
|
||||
.inspector-method {
|
||||
flex: 1;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
.inspector-payload {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
background-color: #21262d;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
background-color: #30363d;
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
color: #e6edf3;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef, useCallback } = React;
|
||||
|
||||
function McpHubAdmin() {
|
||||
const [connectionStatus, setConnectionStatus] = useState('disconnected');
|
||||
const [reconnectCount, setReconnectCount] = useState(0);
|
||||
const [lastEventTime, setLastEventTime] = useState(null);
|
||||
const [hubUrl, setHubUrl] = useState(() => localStorage.getItem('hubUrl') || 'wss://mcp.arik.work/ws/observe');
|
||||
const [secret, setSecret] = useState(() => localStorage.getItem('observeSecret') || '');
|
||||
const [showSettings, setShowSettings] = useState(!localStorage.getItem('observeSecret'));
|
||||
|
||||
const [backends, setBackends] = useState({});
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [logFilter, setLogFilter] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(true);
|
||||
const [selectedMessage, setSelectedMessage] = useState(null);
|
||||
const [messageFilter, setMessageFilter] = useState('');
|
||||
|
||||
const ws = useRef(null);
|
||||
const reconnectTimeout = useRef(null);
|
||||
const backoffMs = useRef(1000);
|
||||
const logsEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
setTimeout(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'auto' });
|
||||
}, 0);
|
||||
}
|
||||
}, [autoScroll]);
|
||||
|
||||
const addLog = useCallback((level, message, serviceId = 'system') => {
|
||||
setLogs(prevLogs => {
|
||||
const newLogs = [...prevLogs, {
|
||||
id: Math.random(),
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
message,
|
||||
serviceId
|
||||
}];
|
||||
return newLogs.slice(-500);
|
||||
});
|
||||
setLastEventTime(new Date());
|
||||
}, []);
|
||||
|
||||
const addMessage = useCallback((timestamp, serviceId, direction, method, payload) => {
|
||||
setMessages(prevMessages => {
|
||||
const newMessages = [...prevMessages, {
|
||||
id: Math.random(),
|
||||
timestamp,
|
||||
serviceId,
|
||||
direction,
|
||||
method,
|
||||
payload
|
||||
}];
|
||||
return newMessages.slice(-100);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!secret.trim()) {
|
||||
addLog('error', 'OBSERVE_SECRET not set');
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
addLog('info', `Connecting to ${hubUrl}...`);
|
||||
|
||||
try {
|
||||
ws.current = new WebSocket(hubUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setConnectionStatus('connected');
|
||||
backoffMs.current = 1000;
|
||||
setReconnectCount(0);
|
||||
addLog('info', 'Connected to hub');
|
||||
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'observe',
|
||||
secret: secret.trim()
|
||||
}));
|
||||
addLog('info', 'Sent observe request');
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setLastEventTime(new Date());
|
||||
|
||||
if (data.type === 'snapshot') {
|
||||
addLog('info', `Snapshot: ${Object.keys(data.backends || {}).length} backends`);
|
||||
setBackends(data.backends || {});
|
||||
Object.entries(data.backends || {}).forEach(([id, backend]) => {
|
||||
addMessage(new Date(), id, '←', 'snapshot', backend);
|
||||
});
|
||||
} else if (data.type === 'backend:connected') {
|
||||
const { id } = data;
|
||||
addLog('info', `Backend connected: ${id}`);
|
||||
setBackends(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
id,
|
||||
connected: true,
|
||||
connectedSince: new Date(),
|
||||
lastPing: new Date(),
|
||||
messagesIn: 0,
|
||||
messagesOut: 0
|
||||
}
|
||||
}));
|
||||
addMessage(new Date(), id, '→', 'connected', data);
|
||||
} else if (data.type === 'backend:disconnected') {
|
||||
const { id } = data;
|
||||
addLog('info', `Backend disconnected: ${id}`);
|
||||
setBackends(prev => {
|
||||
const updated = { ...prev };
|
||||
if (updated[id]) {
|
||||
updated[id] = { ...updated[id], connected: false };
|
||||
setTimeout(() => {
|
||||
setBackends(p => {
|
||||
const { [id]: _, ...rest } = p;
|
||||
return rest;
|
||||
});
|
||||
}, 10000);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
addMessage(new Date(), id, '→', 'disconnected', data);
|
||||
} else if (data.type === 'message') {
|
||||
const { serviceId, direction, method } = data;
|
||||
addLog('info', `Message from ${serviceId}: ${method} (${direction})`);
|
||||
setBackends(prev => {
|
||||
const updated = { ...prev };
|
||||
if (updated[serviceId]) {
|
||||
updated[serviceId] = {
|
||||
...updated[serviceId],
|
||||
[direction === 'in' ? 'messagesIn' : 'messagesOut']:
|
||||
(updated[serviceId][direction === 'in' ? 'messagesIn' : 'messagesOut'] || 0) + 1,
|
||||
lastPing: new Date()
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
addMessage(new Date(), serviceId, direction === 'in' ? '←' : '→', method, data.payload);
|
||||
} else if (data.type === 'log') {
|
||||
addLog(data.level || 'info', data.message, data.serviceId);
|
||||
}
|
||||
} catch (err) {
|
||||
addLog('error', `Failed to parse message: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
addLog('error', `WebSocket error: ${error.type}`);
|
||||
setConnectionStatus('disconnected');
|
||||
};
|
||||
|
||||
ws.current.onclose = () => {
|
||||
setConnectionStatus('disconnected');
|
||||
addLog('warn', `Disconnected. Reconnecting in ${backoffMs.current}ms...`);
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
setReconnectCount(prev => prev + 1);
|
||||
backoffMs.current = Math.min(backoffMs.current * 1.5, 30000);
|
||||
connect();
|
||||
}, backoffMs.current);
|
||||
};
|
||||
} catch (err) {
|
||||
addLog('error', `Connection failed: ${err.message}`);
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
}, [hubUrl, secret, addLog, addMessage]);
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
localStorage.setItem('hubUrl', hubUrl);
|
||||
localStorage.setItem('observeSecret', secret);
|
||||
setShowSettings(false);
|
||||
connect();
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [logs, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSettings && secret.trim()) {
|
||||
connect();
|
||||
}
|
||||
return () => {
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
if (ws.current) ws.current.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (!logFilter) return true;
|
||||
return log.message.toLowerCase().includes(logFilter.toLowerCase()) ||
|
||||
log.serviceId.toLowerCase().includes(logFilter.toLowerCase());
|
||||
});
|
||||
|
||||
const filteredMessages = messages.filter(msg => {
|
||||
if (!messageFilter) return true;
|
||||
return msg.serviceId === messageFilter;
|
||||
});
|
||||
|
||||
const serviceIds = [...new Set(messages.map(m => m.serviceId))];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* Status Bar */}
|
||||
<div className="status-bar">
|
||||
<div className="status-indicator">
|
||||
<div className={`status-dot ${connectionStatus}`}></div>
|
||||
<span>{connectionStatus.toUpperCase()}</span>
|
||||
</div>
|
||||
<span>Reconnects: {reconnectCount}</span>
|
||||
<span>Last Event: {lastEventTime ? lastEventTime.toLocaleTimeString() : 'Never'}</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px' }}>
|
||||
{showSettings && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="hub-url-input"
|
||||
placeholder="Hub WebSocket URL"
|
||||
value={hubUrl}
|
||||
onChange={(e) => setHubUrl(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="secret-input"
|
||||
placeholder="OBSERVE_SECRET"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
<button className="button" onClick={handleSaveSettings}>Connect</button>
|
||||
</>
|
||||
)}
|
||||
<button className="button secondary" onClick={() => setShowSettings(!showSettings)}>
|
||||
{showSettings ? 'Cancel' : 'Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Panels */}
|
||||
<div className="content-panels">
|
||||
{/* Backends Panel */}
|
||||
<div className="panel" style={{ flex: '1 1 30%', minHeight: '150px' }}>
|
||||
<div className="panel-header">
|
||||
Connected Backends ({Object.keys(backends).length})
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="backends-grid">
|
||||
{Object.entries(backends).map(([id, backend]) => (
|
||||
<div key={id} className={`backend-card ${backend.connected ? '' : 'disconnected'}`}>
|
||||
<div className="backend-card-title">
|
||||
<div className={`backend-status-dot ${backend.connected ? 'connected' : 'disconnected'}`}></div>
|
||||
<span>{id}</span>
|
||||
</div>
|
||||
<div className="backend-info">
|
||||
<span>Connected Since:</span>
|
||||
<span>
|
||||
{backend.connectedSince ?
|
||||
((new Date() - backend.connectedSince) / 1000).toFixed(0) + 's' : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="backend-info">
|
||||
<span>Last Ping:</span>
|
||||
<span>
|
||||
{backend.lastPing ?
|
||||
((new Date() - backend.lastPing) / 1000).toFixed(0) + 's ago' : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="backend-counter">
|
||||
<span>↓ In: {backend.messagesIn || 0}</span>
|
||||
<span>↑ Out: {backend.messagesOut || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Stream Panel */}
|
||||
<div className="panel" style={{ flex: '1 1 40%', minHeight: '200px' }}>
|
||||
<div className="panel-header">
|
||||
Log Stream ({filteredLogs.length})
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="log-stream">
|
||||
<div className="log-controls">
|
||||
<input
|
||||
type="text"
|
||||
className="log-filter"
|
||||
placeholder="Filter logs..."
|
||||
value={logFilter}
|
||||
onChange={(e) => setLogFilter(e.target.value)}
|
||||
/>
|
||||
<div className="checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoScroll"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="autoScroll">Auto-scroll</label>
|
||||
</div>
|
||||
<button className="button secondary" onClick={handleClearLogs}>Clear</button>
|
||||
</div>
|
||||
<div className="log-lines">
|
||||
{filteredLogs.map(log => (
|
||||
<div key={log.id} className="log-line">
|
||||
<span className="log-timestamp">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={`log-level ${log.level}`}>
|
||||
{log.level.toUpperCase()}
|
||||
</span>
|
||||
<span className="log-message">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Inspector Panel */}
|
||||
<div className="panel" style={{ flex: inspectorCollapsed ? '0 0 auto' : '1 1 30%', minHeight: inspectorCollapsed ? '40px' : '150px' }}>
|
||||
<div className="panel-header" onClick={() => setInspectorCollapsed(!inspectorCollapsed)}>
|
||||
<span className="collapse-toggle">
|
||||
{inspectorCollapsed ? '▶' : '▼'} Message Inspector ({filteredMessages.length})
|
||||
</span>
|
||||
</div>
|
||||
{!inspectorCollapsed && (
|
||||
<div className="panel-body">
|
||||
<div className="message-inspector">
|
||||
<div className="inspector-controls">
|
||||
<select
|
||||
value={messageFilter}
|
||||
onChange={(e) => setMessageFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Services</option>
|
||||
{serviceIds.map(id => (
|
||||
<option key={id} value={id}>{id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="inspector-table">
|
||||
{filteredMessages.map(msg => (
|
||||
<div key={msg.id}>
|
||||
<div
|
||||
className={`inspector-row ${selectedMessage === msg.id ? 'expanded' : ''}`}
|
||||
onClick={() => setSelectedMessage(selectedMessage === msg.id ? null : msg.id)}
|
||||
>
|
||||
<span className="inspector-column">
|
||||
{msg.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="inspector-column">{msg.serviceId}</span>
|
||||
<span className="inspector-direction">{msg.direction}</span>
|
||||
<span className="inspector-method">{msg.method}</span>
|
||||
</div>
|
||||
{selectedMessage === msg.id && (
|
||||
<div className="inspector-payload">
|
||||
{JSON.stringify(msg.payload, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<McpHubAdmin />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,6 +3,9 @@ require('dotenv').config({ path: __dirname + '/.env' });
|
|||
const HUB_SECRET = process.env.HUB_SECRET;
|
||||
if (!HUB_SECRET) throw new Error('HUB_SECRET not set in .env');
|
||||
|
||||
const OBSERVE_SECRET = process.env.OBSERVE_SECRET;
|
||||
if (!OBSERVE_SECRET) throw new Error('OBSERVE_SECRET not set in .env');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
|
|
@ -12,7 +15,11 @@ module.exports = {
|
|||
env: {
|
||||
NODE_ENV: 'development',
|
||||
PORT: 3000,
|
||||
HUB_AUTH: JSON.stringify({ 'sample-mcp': HUB_SECRET, 'memory-mcp': HUB_SECRET })
|
||||
HUB_AUTH: JSON.stringify({ 'sample-mcp': HUB_SECRET, 'memory-mcp': HUB_SECRET }),
|
||||
OBSERVE_SECRET: OBSERVE_SECRET,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || '',
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
OAUTH_ISSUER: process.env.OAUTH_ISSUER || 'https://mcp.arik.work'
|
||||
},
|
||||
max_restarts: 10,
|
||||
restart_delay: 1000,
|
||||
|
|
|
|||
1149
package-lock.json
generated
1149
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,8 +17,10 @@
|
|||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"google-auth-library": "^10.6.1",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ function getServiceSecret(serviceId) {
|
|||
module.exports = {
|
||||
PORT: parseInt(process.env.PORT, 10) || 3000,
|
||||
getServiceSecret,
|
||||
OBSERVE_SECRET: process.env.OBSERVE_SECRET,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ const http = require('http');
|
|||
const app = require('./server');
|
||||
const config = require('./config');
|
||||
const setupWsServer = require('./ws-server');
|
||||
const setupObserveServer = require('./ws-observe');
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
setupWsServer(httpServer);
|
||||
setupObserveServer(httpServer);
|
||||
|
||||
httpServer.listen(config.PORT, () => {
|
||||
console.log(`MCP relay hub listening on port ${config.PORT}`);
|
||||
|
|
|
|||
113
src/oauth-store.js
Normal file
113
src/oauth-store.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '../data/oauth.db');
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
function ensureTables() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
client_secret TEXT NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge TEXT NOT NULL,
|
||||
code_challenge_method TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tokens (
|
||||
access_token TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
refresh_token TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
refresh_token TEXT PRIMARY KEY,
|
||||
access_token TEXT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
user_email TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function cleanupExpired() {
|
||||
const now = Date.now();
|
||||
db.prepare('DELETE FROM auth_codes WHERE expires_at < ?').run(now);
|
||||
db.prepare('DELETE FROM tokens WHERE expires_at < ?').run(now);
|
||||
db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').run(now);
|
||||
}
|
||||
|
||||
function saveClient(client) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO clients (client_id, client_secret, redirect_uris, created_at) VALUES (?, ?, ?, ?)'
|
||||
).run(client.client_id, client.client_secret, client.redirect_uris, client.created_at);
|
||||
}
|
||||
|
||||
function getClient(client_id) {
|
||||
return db.prepare('SELECT * FROM clients WHERE client_id = ?').get(client_id);
|
||||
}
|
||||
|
||||
function saveAuthCode(code, codeData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO auth_codes (code, client_id, redirect_uri, code_challenge, code_challenge_method, user_email, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(code, codeData.client_id, codeData.redirect_uri, codeData.code_challenge, codeData.code_challenge_method, codeData.user_email, codeData.expires_at);
|
||||
}
|
||||
|
||||
function getAuthCode(code) {
|
||||
return db.prepare('SELECT * FROM auth_codes WHERE code = ?').get(code);
|
||||
}
|
||||
|
||||
function deleteAuthCode(code) {
|
||||
db.prepare('DELETE FROM auth_codes WHERE code = ?').run(code);
|
||||
}
|
||||
|
||||
function saveToken(access_token, tokenData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO tokens (access_token, client_id, user_email, expires_at, refresh_token) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(access_token, tokenData.client_id, tokenData.user_email, tokenData.expires_at, tokenData.refresh_token);
|
||||
}
|
||||
|
||||
function getToken(access_token) {
|
||||
return db.prepare('SELECT * FROM tokens WHERE access_token = ?').get(access_token);
|
||||
}
|
||||
|
||||
function deleteToken(access_token) {
|
||||
db.prepare('DELETE FROM tokens WHERE access_token = ?').run(access_token);
|
||||
}
|
||||
|
||||
function saveRefreshToken(refresh_token, rtData) {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO refresh_tokens (refresh_token, access_token, client_id, user_email, expires_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(refresh_token, rtData.access_token, rtData.client_id, rtData.user_email, rtData.expires_at);
|
||||
}
|
||||
|
||||
function getRefreshToken(refresh_token) {
|
||||
return db.prepare('SELECT * FROM refresh_tokens WHERE refresh_token = ?').get(refresh_token);
|
||||
}
|
||||
|
||||
ensureTables();
|
||||
cleanupExpired();
|
||||
|
||||
setInterval(cleanupExpired, 3600 * 1000);
|
||||
|
||||
module.exports = {
|
||||
saveClient,
|
||||
getClient,
|
||||
saveAuthCode,
|
||||
getAuthCode,
|
||||
deleteAuthCode,
|
||||
saveToken,
|
||||
getToken,
|
||||
deleteToken,
|
||||
saveRefreshToken,
|
||||
getRefreshToken,
|
||||
cleanupExpired,
|
||||
};
|
||||
293
src/oauth.js
Normal file
293
src/oauth.js
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const https = require('https');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
const store = require('./oauth-store');
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
|
||||
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
|
||||
const OAUTH_ISSUER = process.env.OAUTH_ISSUER || 'https://mcp.arik.work';
|
||||
|
||||
const wellKnownRouter = express.Router();
|
||||
const oauthRouter = express.Router();
|
||||
|
||||
function validateOAuthToken(req, res, next) {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}"`);
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
const tokenData = store.getToken(auth.slice(7));
|
||||
if (!tokenData || Date.now() > tokenData.expires_at) {
|
||||
res.setHeader('WWW-Authenticate', `Bearer realm="${OAUTH_ISSUER}", error="invalid_token"`);
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
req.user = { email: tokenData.user_email };
|
||||
next();
|
||||
}
|
||||
|
||||
wellKnownRouter.get('/oauth-protected-resource', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
resource: OAUTH_ISSUER,
|
||||
authorization_servers: [OAUTH_ISSUER],
|
||||
bearer_methods_supported: ['header'],
|
||||
scopes_supported: ['openid', 'email', 'profile'],
|
||||
});
|
||||
});
|
||||
|
||||
wellKnownRouter.get('/oauth-authorization-server', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
issuer: OAUTH_ISSUER,
|
||||
authorization_endpoint: OAUTH_ISSUER + '/oauth/authorize',
|
||||
token_endpoint: OAUTH_ISSUER + '/oauth/token',
|
||||
registration_endpoint: OAUTH_ISSUER + '/oauth/register',
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
});
|
||||
});
|
||||
|
||||
oauthRouter.post('/register', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const client_id = crypto.randomUUID();
|
||||
const client_secret = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
store.saveClient({
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris: JSON.stringify(req.body.redirect_uris || []),
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
res.json({
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris: req.body.redirect_uris || [],
|
||||
client_id_issued_at: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
oauthRouter.get('/authorize', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
const client_id = req.query.client_id;
|
||||
const redirect_uri = req.query.redirect_uri;
|
||||
|
||||
const client = store.getClient(client_id);
|
||||
if (!client) {
|
||||
return res.status(400).json({ error: 'invalid_client' });
|
||||
}
|
||||
|
||||
const redirect_uris = JSON.parse(client.redirect_uris);
|
||||
if (!redirect_uris.includes(redirect_uri)) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
||||
}
|
||||
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
return res.status(503).json({ error: 'oauth_not_configured' });
|
||||
}
|
||||
|
||||
const state = Buffer.from(JSON.stringify({
|
||||
client_id,
|
||||
redirect_uri,
|
||||
code_challenge: req.query.code_challenge,
|
||||
code_challenge_method: req.query.code_challenge_method,
|
||||
original_state: req.query.state,
|
||||
})).toString('base64url');
|
||||
|
||||
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
authUrl.searchParams.set('client_id', GOOGLE_CLIENT_ID);
|
||||
authUrl.searchParams.set('redirect_uri', OAUTH_ISSUER + '/oauth/google/callback');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'openid email profile');
|
||||
authUrl.searchParams.set('state', state);
|
||||
authUrl.searchParams.set('access_type', 'offline');
|
||||
authUrl.searchParams.set('prompt', 'select_account');
|
||||
|
||||
res.redirect(authUrl.toString());
|
||||
});
|
||||
|
||||
oauthRouter.get('/google/callback', async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
try {
|
||||
const params = JSON.parse(Buffer.from(req.query.state, 'base64url').toString());
|
||||
|
||||
const tokenResponse = await new Promise((resolve, reject) => {
|
||||
const postData = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: req.query.code,
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: OAUTH_ISSUER + '/oauth/google/callback',
|
||||
}).toString();
|
||||
|
||||
const options = {
|
||||
hostname: 'oauth2.googleapis.com',
|
||||
port: 443,
|
||||
path: '/token',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
};
|
||||
|
||||
const request = https.request(options, (response) => {
|
||||
let body = '';
|
||||
response.on('data', (chunk) => { body += chunk; });
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.write(postData);
|
||||
request.end();
|
||||
});
|
||||
|
||||
const oauthClient = new OAuth2Client(GOOGLE_CLIENT_ID);
|
||||
const ticket = await oauthClient.verifyIdToken({
|
||||
idToken: tokenResponse.id_token,
|
||||
audience: GOOGLE_CLIENT_ID,
|
||||
});
|
||||
const { email } = ticket.getPayload();
|
||||
|
||||
const code = crypto.randomBytes(32).toString('hex');
|
||||
store.saveAuthCode(code, {
|
||||
client_id: params.client_id,
|
||||
redirect_uri: params.redirect_uri,
|
||||
code_challenge: params.code_challenge,
|
||||
code_challenge_method: params.code_challenge_method,
|
||||
user_email: email,
|
||||
expires_at: Date.now() + 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const redirectUrl = new URL(params.redirect_uri);
|
||||
redirectUrl.searchParams.set('code', code);
|
||||
if (params.original_state) {
|
||||
redirectUrl.searchParams.set('state', params.original_state);
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('[oauth] google/callback error:', error.message);
|
||||
res.status(500).json({ error: 'authentication_failed', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
oauthRouter.post('/token', (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
try {
|
||||
const grant_type = req.body.grant_type;
|
||||
|
||||
if (grant_type === 'authorization_code') {
|
||||
const codeData = store.getAuthCode(req.body.code);
|
||||
if (!codeData) {
|
||||
return res.status(400).json({ error: 'invalid_code' });
|
||||
}
|
||||
if (Date.now() > codeData.expires_at) {
|
||||
store.deleteAuthCode(req.body.code);
|
||||
return res.status(400).json({ error: 'expired_code' });
|
||||
}
|
||||
if (codeData.redirect_uri !== req.body.redirect_uri) {
|
||||
return res.status(400).json({ error: 'invalid_redirect_uri' });
|
||||
}
|
||||
|
||||
if (codeData.code_challenge_method === 'S256') {
|
||||
const cv = req.body.code_verifier;
|
||||
if (!cv) {
|
||||
return res.status(400).json({ error: 'invalid_request', error_description: 'code_verifier required' });
|
||||
}
|
||||
const expected = Buffer.from(crypto.createHash('sha256').update(cv).digest())
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
if (expected !== codeData.code_challenge) {
|
||||
return res.status(400).json({ error: 'invalid_code_verifier' });
|
||||
}
|
||||
}
|
||||
|
||||
store.deleteAuthCode(req.body.code);
|
||||
|
||||
const access_token = crypto.randomBytes(32).toString('hex');
|
||||
const refresh_token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
store.saveToken(access_token, {
|
||||
client_id: codeData.client_id,
|
||||
user_email: codeData.user_email,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
refresh_token,
|
||||
});
|
||||
|
||||
store.saveRefreshToken(refresh_token, {
|
||||
access_token,
|
||||
client_id: codeData.client_id,
|
||||
user_email: codeData.user_email,
|
||||
expires_at: Date.now() + 30 * 24 * 3600 * 1000,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token,
|
||||
scope: 'openid email profile',
|
||||
});
|
||||
} else if (grant_type === 'refresh_token') {
|
||||
const rtData = store.getRefreshToken(req.body.refresh_token);
|
||||
if (!rtData) {
|
||||
return res.status(400).json({ error: 'invalid_refresh_token' });
|
||||
}
|
||||
if (Date.now() > rtData.expires_at) {
|
||||
return res.status(400).json({ error: 'expired_refresh_token' });
|
||||
}
|
||||
|
||||
const new_token = crypto.randomBytes(32).toString('hex');
|
||||
store.deleteToken(rtData.access_token);
|
||||
|
||||
store.saveToken(new_token, {
|
||||
client_id: rtData.client_id,
|
||||
user_email: rtData.user_email,
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
refresh_token: req.body.refresh_token,
|
||||
});
|
||||
|
||||
store.saveRefreshToken(req.body.refresh_token, {
|
||||
access_token: new_token,
|
||||
client_id: rtData.client_id,
|
||||
user_email: rtData.user_email,
|
||||
expires_at: rtData.expires_at,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
access_token: new_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({ error: 'unsupported_grant_type' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[oauth] token error:', error.message);
|
||||
res.status(500).json({ error: 'server_error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
wellKnownRouter,
|
||||
oauthRouter,
|
||||
validateOAuthToken,
|
||||
};
|
||||
|
|
@ -14,6 +14,33 @@ app.use((req, res, next) => {
|
|||
next();
|
||||
});
|
||||
|
||||
// CORS — must be first
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const isPublic = req.path.startsWith('/.well-known') || req.path.startsWith('/oauth');
|
||||
if (isPublic) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
} else if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||||
next();
|
||||
});
|
||||
|
||||
// Public OAuth + well-known (no token required)
|
||||
const { wellKnownRouter, oauthRouter, validateOAuthToken } = require('./oauth');
|
||||
app.use('/.well-known', wellKnownRouter);
|
||||
app.use('/oauth', oauthRouter);
|
||||
|
||||
// Token gate — /health exempt
|
||||
app.use((req, res, next) => {
|
||||
if (req.path === '/health') return next();
|
||||
validateOAuthToken(req, res, next);
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
|
|
|
|||
109
src/ws-observe.js
Normal file
109
src/ws-observe.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
const WebSocket = require('ws');
|
||||
const config = require('./config');
|
||||
const registry = require('./backend-registry');
|
||||
const eventBus = require('./event-bus');
|
||||
const { log } = require('./event-bus');
|
||||
|
||||
function setupObserveServer(httpServer) {
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const authenticatedObservers = new Set();
|
||||
const observerListeners = new Map();
|
||||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
if (req.url !== '/ws/observe') {
|
||||
return; // Let other handlers process it
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
let authenticated = false;
|
||||
|
||||
ws.once('message', (data) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch {
|
||||
log('error', '[ws-observe] Failed to parse handshake message');
|
||||
ws.close(4001, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type !== 'observe' || !msg.secret) {
|
||||
log('error', '[ws-observe] Invalid handshake: missing type or secret');
|
||||
ws.close(4001, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.secret !== config.OBSERVE_SECRET) {
|
||||
log('error', `[ws-observe] auth failed from ${req.socket.remoteAddress}`);
|
||||
ws.close(4001, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
authenticated = true;
|
||||
authenticatedObservers.add(ws);
|
||||
log('info', '[ws-observe] observer authenticated');
|
||||
|
||||
// Send initial snapshot
|
||||
const snapshot = {
|
||||
type: 'snapshot',
|
||||
ts: new Date().toISOString(),
|
||||
backends: registry.list(),
|
||||
activeSessions: []
|
||||
};
|
||||
ws.send(JSON.stringify(snapshot));
|
||||
|
||||
// Subscribe to all eventBus events and forward to this observer
|
||||
const eventTypes = ['backend:connected', 'backend:disconnected', 'log', 'session:created', 'session:ended'];
|
||||
const listeners = {};
|
||||
|
||||
eventTypes.forEach((eventName) => {
|
||||
const listener = (payload) => {
|
||||
const frame = { type: eventName, ...payload };
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(frame));
|
||||
} else {
|
||||
log('warn', `[ws-observe] ws not open for ${eventName}, state: ${ws.readyState}`);
|
||||
}
|
||||
};
|
||||
listeners[eventName] = listener;
|
||||
eventBus.on(eventName, listener);
|
||||
});
|
||||
|
||||
log('info', `[ws-observe] registered ${eventTypes.length} event listeners`);
|
||||
|
||||
observerListeners.set(ws, { listeners, eventTypes });
|
||||
|
||||
// Ignore any subsequent messages
|
||||
ws.on('message', () => {
|
||||
// Silently ignore - observers are read-only after handshake
|
||||
});
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (authenticated) {
|
||||
authenticatedObservers.delete(ws);
|
||||
const observerData = observerListeners.get(ws);
|
||||
if (observerData) {
|
||||
const { listeners, eventTypes } = observerData;
|
||||
eventTypes.forEach((eventName) => {
|
||||
eventBus.removeListener(eventName, listeners[eventName]);
|
||||
});
|
||||
observerListeners.delete(ws);
|
||||
}
|
||||
log('info', '[ws-observe] observer disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
log('error', `[ws-observe] error: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = setupObserveServer;
|
||||
|
|
@ -13,8 +13,7 @@ function setupWsServer(httpServer) {
|
|||
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
if (req.url !== '/ws/register') {
|
||||
socket.destroy();
|
||||
return;
|
||||
return; // Let other handlers process it
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
require('dotenv').config({ path: __dirname + '/../.env' });
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_ID !== 'your-google-client-id-here') {
|
||||
console.log('[test] OAuth enabled — skipping E2E (requires browser flow).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const HUB_PORT = 3000;
|
||||
const TOTAL_TIMEOUT_MS = 15000;
|
||||
const POLL_INTERVAL_MS = 200;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue