Compare commits
No commits in common. "4e78557158040c6a62029ca728f46a22fa37363b" and "84a804c57ae151940480a97156b45f03d70e20e0" have entirely different histories.
4e78557158
...
84a804c57a
14 changed files with 3 additions and 2562 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,4 +2,3 @@ agent_gateway.db
|
||||||
.env
|
.env
|
||||||
mcp-bridge/node_modules
|
mcp-bridge/node_modules
|
||||||
node_modules/
|
node_modules/
|
||||||
data/
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,788 +0,0 @@
|
||||||
<!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,9 +3,6 @@ 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: [
|
||||||
{
|
{
|
||||||
|
|
@ -15,11 +12,7 @@ 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,10 +17,8 @@
|
||||||
"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,5 +24,4 @@ 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,11 +2,9 @@ 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}`);
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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
293
src/oauth.js
|
|
@ -1,293 +0,0 @@
|
||||||
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,33 +14,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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,7 +13,8 @@ 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') {
|
||||||
return; // Let other handlers process it
|
socket.destroy();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
wss.emit('connection', ws, req);
|
wss.emit('connection', ws, req);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
'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