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
|
.env
|
||||||
mcp-bridge/node_modules
|
mcp-bridge/node_modules
|
||||||
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;
|
const HUB_SECRET = process.env.HUB_SECRET;
|
||||||
if (!HUB_SECRET) throw new Error('HUB_SECRET not set in .env');
|
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 = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
|
|
@ -12,7 +15,11 @@ module.exports = {
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'development',
|
NODE_ENV: 'development',
|
||||||
PORT: 3000,
|
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,
|
max_restarts: 10,
|
||||||
restart_delay: 1000,
|
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",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"google-auth-library": "^10.6.1",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ function getServiceSecret(serviceId) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PORT: parseInt(process.env.PORT, 10) || 3000,
|
PORT: parseInt(process.env.PORT, 10) || 3000,
|
||||||
getServiceSecret,
|
getServiceSecret,
|
||||||
|
OBSERVE_SECRET: process.env.OBSERVE_SECRET,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ const http = require('http');
|
||||||
const app = require('./server');
|
const app = require('./server');
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const setupWsServer = require('./ws-server');
|
const setupWsServer = require('./ws-server');
|
||||||
|
const setupObserveServer = require('./ws-observe');
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
const httpServer = http.createServer(app);
|
||||||
setupWsServer(httpServer);
|
setupWsServer(httpServer);
|
||||||
|
setupObserveServer(httpServer);
|
||||||
|
|
||||||
httpServer.listen(config.PORT, () => {
|
httpServer.listen(config.PORT, () => {
|
||||||
console.log(`MCP relay hub listening on port ${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();
|
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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
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) => {
|
httpServer.on('upgrade', (req, socket, head) => {
|
||||||
if (req.url !== '/ws/register') {
|
if (req.url !== '/ws/register') {
|
||||||
socket.destroy();
|
return; // Let other handlers process it
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
wss.emit('connection', ws, req);
|
wss.emit('connection', ws, req);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
require('dotenv').config({ path: __dirname + '/../.env' });
|
||||||
|
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
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 HUB_PORT = 3000;
|
||||||
const TOTAL_TIMEOUT_MS = 15000;
|
const TOTAL_TIMEOUT_MS = 15000;
|
||||||
const POLL_INTERVAL_MS = 200;
|
const POLL_INTERVAL_MS = 200;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue