First
Some checks failed
Build All Docker Images / changes (push) Has been cancelled
Build and Push App Docker Image / build (push) Has been cancelled
Build and Push Node Docker Image / build (push) Has been cancelled
Test and Lint / test-app (push) Has been cancelled
Test and Lint / test-node (push) Has been cancelled
Test and Lint / lint-dockerfiles (push) Has been cancelled
Test and Lint / security-scan (push) Has been cancelled
Build All Docker Images / build-app (push) Has been cancelled
Build All Docker Images / build-node (push) Has been cancelled
Build All Docker Images / summary (push) Has been cancelled

This commit is contained in:
hunternick87 2025-07-03 15:50:13 -04:00
commit 4169337dd0
68 changed files with 8726 additions and 0 deletions

922
app/src/client/App.css Normal file
View file

@ -0,0 +1,922 @@
/* Base styles */
:root {
--primary: #0066cc;
--primary-hover: #0052a3;
--secondary: #6c757d;
--success: #28a745;
--danger: #dc3545;
--warning: #ffc107;
--info: #17a2b8;
--light: #f8f9fa;
--dark: #343a40;
--border: #dee2e6;
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #212529;
--text-secondary: #6c757d;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.App {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Navigation */
.navbar {
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow);
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--primary);
}
.navbar-icon {
color: var(--primary);
}
.navbar-nav {
display: flex;
gap: 1rem;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
text-decoration: none;
color: var(--text-secondary);
transition: all 0.2s;
}
.nav-link:hover,
.nav-link.active {
background-color: var(--primary);
color: white;
}
/* Main content */
.main-content {
flex: 1;
padding: 2rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
background-color: transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary);
border-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-secondary {
background-color: var(--secondary);
border-color: var(--secondary);
color: white;
}
.btn-success {
background-color: var(--success);
border-color: var(--success);
color: white;
}
.btn-danger {
background-color: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-warning {
background-color: var(--warning);
border-color: var(--warning);
color: var(--dark);
}
.btn-ghost {
background-color: transparent;
border-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background-color: var(--bg-secondary);
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Dashboard */
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.dashboard-header {
margin-bottom: 2rem;
}
.dashboard-header h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.dashboard-header p {
font-size: 1.125rem;
color: var(--text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: var(--shadow);
}
.stat-icon {
flex-shrink: 0;
}
.stat-content h3 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.stat-description {
font-size: 0.75rem;
color: var(--text-secondary);
}
.dashboard-sections {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.section {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.tunnel-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tunnel-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background-color: var(--bg-secondary);
}
.tunnel-info h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.tunnel-info p {
font-size: 0.875rem;
color: var(--text-secondary);
}
.status-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.status-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background-color: var(--bg-secondary);
}
.status-indicator {
flex-shrink: 0;
}
.status-content h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.status-content p {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Tunnel Manager */
.tunnel-manager {
max-width: 1200px;
margin: 0 auto;
}
.tunnel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.tunnel-header h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.node-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.node-indicator {
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.node-indicator.online {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.node-indicator.offline {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tunnels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
.tunnel-card {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.tunnel-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.tunnel-title h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.tunnel-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tunnel-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.tunnel-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.info-value {
font-size: 0.875rem;
color: var(--text-primary);
font-family: monospace;
}
.tunnel-error {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
}
.error-text {
font-size: 0.875rem;
color: #dc2626;
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.badge-success {
background-color: #d1fae5;
color: #065f46;
}
.badge-secondary {
background-color: #f3f4f6;
color: #374151;
}
.badge-active {
background-color: #dbeafe;
color: #1e40af;
}
.badge-inactive {
background-color: #fef3c7;
color: #92400e;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-active {
background-color: #d1fae5;
color: #065f46;
}
.status-inactive {
background-color: #fef3c7;
color: #92400e;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-icon {
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.empty-state p {
margin-bottom: 1.5rem;
}
/* Form overlay */
.form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.form-modal {
background-color: var(--bg-primary);
border-radius: 0.5rem;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.form-header h2 {
font-size: 1.5rem;
font-weight: 600;
}
.tunnel-form {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.25rem;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
/* Server Status */
.server-status {
max-width: 1200px;
margin: 0 auto;
}
.server-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.server-header h1 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
}
.server-info {
display: flex;
align-items: center;
gap: 1rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
background-color: var(--bg-primary);
border: 1px solid var(--border);
}
.status-text {
font-size: 1rem;
font-weight: 600;
}
.server-controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.control-section {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.control-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.control-buttons {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.status-section {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.status-section h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background-color: var(--bg-secondary);
}
.info-label {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 500;
}
.info-value {
font-size: 0.875rem;
color: var(--text-primary);
font-family: monospace;
}
/* Logs */
.logs-section {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
margin-bottom: 2rem;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.logs-header h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.logs-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.logs-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.875rem;
}
.logs-container {
background-color: var(--dark);
color: var(--light);
border-radius: 0.375rem;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.4;
max-height: 400px;
overflow-y: auto;
}
.logs-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.logs-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-secondary);
}
/* System info */
.system-info {
margin-bottom: 2rem;
}
.system-info h2 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.info-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.info-card {
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.info-card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.info-card-header h3 {
font-size: 1.25rem;
font-weight: 600;
}
.info-card-content p {
margin-bottom: 0.5rem;
color: var(--text-secondary);
}
.info-detail {
font-size: 0.875rem;
font-weight: 500;
}
/* Utility classes */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-secondary);
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.text-green-500 {
color: #10b981;
}
.text-red-500 {
color: #ef4444;
}
.text-yellow-500 {
color: #f59e0b;
}
.text-blue-500 {
color: #3b82f6;
}
/* Responsive design */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
}
.navbar-nav {
justify-content: center;
}
.main-content {
padding: 1rem;
}
.dashboard-sections {
grid-template-columns: 1fr;
}
.server-controls {
grid-template-columns: 1fr;
}
.control-buttons {
flex-direction: column;
}
.tunnels-grid {
grid-template-columns: 1fr;
}
.form-modal {
width: 95%;
}
.form-row {
grid-template-columns: 1fr;
}
.tunnel-header {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
}

51
app/src/client/App.tsx Normal file
View file

@ -0,0 +1,51 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toaster } from 'react-hot-toast';
import Navbar from './components/Navbar';
import Dashboard from './pages/Dashboard';
import TunnelManager from './pages/TunnelManager';
import ServerStatus from './pages/ServerStatus';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 1000, // 5 seconds
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<div className="App">
<Navbar />
<main className="main-content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/tunnels" element={<TunnelManager />} />
<Route path="/status" element={<ServerStatus />} />
</Routes>
</main>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
</div>
</Router>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;

View file

@ -0,0 +1,199 @@
import axios from 'axios';
export interface TunnelConfig {
id?: string;
name: string;
protocol: 'TCP' | 'UDP';
localIp: string;
localPort: number;
remotePort: number;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface TunnelStatus {
id: string;
name: string;
active: boolean;
lastChecked: string;
error?: string;
}
export interface FrpcStatus {
running: boolean;
}
export interface FrpcLogs {
logs: string;
}
export interface NodeStatus {
status: string;
timestamp: string;
uptime?: number;
memory?: {
used: number;
total: number;
};
cpu?: {
usage: number;
};
connection?: {
url: string;
isOnline: boolean;
lastConnectionTime: Date | null;
};
}
export interface NodeConnection {
url: string;
isOnline: boolean;
lastConnectionTime: Date | null;
isReachable: boolean;
}
const API_BASE_URL = '/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
});
// Request interceptor for logging
apiClient.interceptors.request.use(
(config) => {
console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
console.error('API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Response Error:', error);
return Promise.reject(error);
}
);
export const tunnelApi = {
// Get all tunnels
getAllTunnels: async (): Promise<TunnelConfig[]> => {
const response = await apiClient.get('/tunnels');
return response.data;
},
// Get tunnel by ID
getTunnelById: async (id: string): Promise<TunnelConfig> => {
const response = await apiClient.get(`/tunnels/${id}`);
return response.data;
},
// Create new tunnel
createTunnel: async (tunnel: Omit<TunnelConfig, 'id' | 'createdAt' | 'updatedAt'>): Promise<TunnelConfig> => {
const response = await apiClient.post('/tunnels', tunnel);
return response.data;
},
// Update tunnel
updateTunnel: async (id: string, tunnel: Partial<TunnelConfig>): Promise<TunnelConfig> => {
const response = await apiClient.put(`/tunnels/${id}`, tunnel);
return response.data;
},
// Delete tunnel
deleteTunnel: async (id: string): Promise<void> => {
await apiClient.delete(`/tunnels/${id}`);
},
// Get tunnel status
getTunnelStatus: async (id: string): Promise<TunnelStatus> => {
const response = await apiClient.get(`/tunnels/${id}/status`);
return response.data;
},
// Get all tunnel statuses
getAllTunnelStatuses: async (): Promise<TunnelStatus[]> => {
const response = await apiClient.get('/tunnels-status');
return response.data;
},
};
export const frpcApi = {
// Get frpc status
getStatus: async (): Promise<FrpcStatus> => {
const response = await apiClient.get('/frpc/status');
return response.data;
},
// Control frpc service
start: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/frpc/start');
return response.data;
},
stop: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/frpc/stop');
return response.data;
},
restart: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/frpc/restart');
return response.data;
},
regenerate: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/frpc/regenerate');
return response.data;
},
// Get frpc logs
getLogs: async (lines: number = 50): Promise<FrpcLogs> => {
const response = await apiClient.get(`/frpc/logs?lines=${lines}`);
return response.data;
},
};
export const nodeApi = {
// Get node status
getStatus: async (): Promise<NodeStatus> => {
const response = await apiClient.get('/node/status');
return response.data;
},
// Get node connection info
getConnection: async (): Promise<NodeConnection> => {
const response = await apiClient.get('/node/connection');
return response.data;
},
// Push configuration to node
pushConfig: async (): Promise<{ message: string; tunnelCount: number; nodeResponse: any }> => {
const response = await apiClient.post('/node/push-config');
return response.data;
},
// Restart frpc on node
restartFrpc: async (): Promise<{ message: string; nodeResponse: any }> => {
const response = await apiClient.post('/node/restart-frpc');
return response.data;
},
// Push config and restart frpc on node
pushAndRestart: async (): Promise<{
message: string;
tunnelCount: number;
configResponse: any;
restartResponse: any
}> => {
const response = await apiClient.post('/node/push-and-restart');
return response.data;
},
};
export default apiClient;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,43 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Server, Settings, Activity } from 'lucide-react';
const Navbar: React.FC = () => {
const location = useLocation();
const isActive = (path: string) => location.pathname === path;
return (
<nav className="navbar">
<div className="navbar-brand">
<Server className="navbar-icon" />
<span>FRP Manager</span>
</div>
<div className="navbar-nav">
<Link
to="/"
className={`nav-link ${isActive('/') ? 'active' : ''}`}
>
<Activity size={18} />
Dashboard
</Link>
<Link
to="/tunnels"
className={`nav-link ${isActive('/tunnels') ? 'active' : ''}`}
>
<Settings size={18} />
Tunnels
</Link>
<Link
to="/status"
className={`nav-link ${isActive('/status') ? 'active' : ''}`}
>
<Server size={18} />
Server Status
</Link>
</div>
</nav>
);
};
export default Navbar;

View file

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { tunnelApi, TunnelConfig } from '../api/client';
import { X, Save, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface TunnelFormProps {
tunnel?: TunnelConfig | null;
onClose: () => void;
onSubmit: () => void;
}
const TunnelForm: React.FC<TunnelFormProps> = ({ tunnel, onClose, onSubmit }) => {
const queryClient = useQueryClient();
const isEditing = Boolean(tunnel);
const [formData, setFormData] = useState({
name: tunnel?.name || '',
protocol: tunnel?.protocol || 'TCP' as 'TCP' | 'UDP',
localIp: tunnel?.localIp || '127.0.0.1',
localPort: tunnel?.localPort || 8080,
remotePort: tunnel?.remotePort || 8080,
enabled: tunnel?.enabled ?? true,
});
const createMutation = useMutation({
mutationFn: tunnelApi.createTunnel,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
toast.success('Tunnel created successfully');
onSubmit();
},
onError: (error) => {
console.error('Create error:', error);
toast.error('Failed to create tunnel');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: string; updates: Partial<TunnelConfig> }) =>
tunnelApi.updateTunnel(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
toast.success('Tunnel updated successfully');
onSubmit();
},
onError: (error) => {
console.error('Update error:', error);
toast.error('Failed to update tunnel');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Please enter a name');
return;
}
if (formData.localPort < 1 || formData.localPort > 65535) {
toast.error('Local port must be between 1 and 65535');
return;
}
if (formData.remotePort < 1 || formData.remotePort > 65535) {
toast.error('Remote port must be between 1 and 65535');
return;
}
if (isEditing && tunnel) {
updateMutation.mutate({
id: tunnel.id!,
updates: formData,
});
} else {
createMutation.mutate(formData);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
if (type === 'checkbox') {
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({
...prev,
[name]: checked,
}));
} else if (type === 'number') {
setFormData(prev => ({
...prev,
[name]: parseInt(value) || 0,
}));
} else {
setFormData(prev => ({
...prev,
[name]: value,
}));
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div className="form-overlay">
<div className="form-modal">
<div className="form-header">
<h2>{isEditing ? 'Edit Tunnel' : 'Create New Tunnel'}</h2>
<button
className="btn btn-ghost btn-sm"
onClick={onClose}
disabled={isPending}
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="tunnel-form">
<div className="form-group">
<label htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="e.g., Minecraft Server"
required
disabled={isPending}
/>
</div>
<div className="form-group">
<label htmlFor="protocol">Protocol</label>
<select
id="protocol"
name="protocol"
value={formData.protocol}
onChange={handleChange}
disabled={isPending}
>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="localIp">Local IP</label>
<input
type="text"
id="localIp"
name="localIp"
value={formData.localIp}
onChange={handleChange}
placeholder="127.0.0.1"
required
disabled={isPending}
/>
</div>
<div className="form-group">
<label htmlFor="localPort">Local Port</label>
<input
type="number"
id="localPort"
name="localPort"
value={formData.localPort}
onChange={handleChange}
min="1"
max="65535"
required
disabled={isPending}
/>
</div>
</div>
<div className="form-group">
<label htmlFor="remotePort">Remote Port</label>
<input
type="number"
id="remotePort"
name="remotePort"
value={formData.remotePort}
onChange={handleChange}
min="1"
max="65535"
required
disabled={isPending}
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
name="enabled"
checked={formData.enabled}
onChange={handleChange}
disabled={isPending}
/>
<span>Enable this tunnel</span>
</label>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={isPending}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={isPending}
>
{isPending && <Loader2 className="animate-spin" size={16} />}
<Save size={16} />
{isEditing ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
};
export default TunnelForm;

69
app/src/client/index.css Normal file
View file

@ -0,0 +1,69 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

12
app/src/client/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,213 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { tunnelApi, frpcApi, nodeApi } from '../api/client';
import { CheckCircle, XCircle, AlertCircle, RefreshCw, Wifi, WifiOff } from 'lucide-react';
const Dashboard: React.FC = () => {
const { data: tunnels, isLoading: tunnelsLoading } = useQuery({
queryKey: ['tunnels'],
queryFn: tunnelApi.getAllTunnels,
refetchInterval: 5000,
});
const { data: tunnelStatuses, isLoading: statusLoading } = useQuery({
queryKey: ['tunnel-statuses'],
queryFn: tunnelApi.getAllTunnelStatuses,
refetchInterval: 10000,
});
const { data: frpcStatus, isLoading: frpcLoading } = useQuery({
queryKey: ['frpc-status'],
queryFn: frpcApi.getStatus,
refetchInterval: 5000,
});
const { data: nodeConnection } = useQuery({
queryKey: ['node-connection'],
queryFn: nodeApi.getConnection,
refetchInterval: 30000,
retry: false,
});
const { data: nodeStatus } = useQuery({
queryKey: ['node-status'],
queryFn: nodeApi.getStatus,
refetchInterval: 30000,
retry: false,
enabled: !!nodeConnection?.isReachable,
});
const activeTunnels = tunnels?.filter(t => t.enabled) || [];
const activeTunnelStatuses = tunnelStatuses?.filter(s => s.active) || [];
if (tunnelsLoading || statusLoading || frpcLoading) {
return (
<div className="dashboard">
<div className="loading">
<RefreshCw className="animate-spin" size={24} />
<span>Loading dashboard...</span>
</div>
</div>
);
}
return (
<div className="dashboard">
<div className="dashboard-header">
<h1>FRP Manager Dashboard</h1>
<p>Manage your tunnel configurations and monitor their status</p>
</div>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">
<CheckCircle className="text-green-500" size={24} />
</div>
<div className="stat-content">
<h3>Active Tunnels</h3>
<p className="stat-number">{activeTunnelStatuses.length}</p>
<p className="stat-description">Currently running</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">
<AlertCircle className="text-yellow-500" size={24} />
</div>
<div className="stat-content">
<h3>Total Tunnels</h3>
<p className="stat-number">{tunnels?.length || 0}</p>
<p className="stat-description">Configured tunnels</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">
{frpcStatus?.running ? (
<CheckCircle className="text-green-500" size={24} />
) : (
<XCircle className="text-red-500" size={24} />
)}
</div>
<div className="stat-content">
<h3>FRPC Service</h3>
<p className="stat-number">{frpcStatus?.running ? 'Running' : 'Stopped'}</p>
<p className="stat-description">Service status</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">
<CheckCircle className="text-blue-500" size={24} />
</div>
<div className="stat-content">
<h3>Enabled Tunnels</h3>
<p className="stat-number">{activeTunnels.length}</p>
<p className="stat-description">Ready to connect</p>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">
{nodeConnection?.isReachable ? (
<Wifi className="text-green-500" size={24} />
) : (
<WifiOff className="text-red-500" size={24} />
)}
</div>
<div className="stat-content">
<h3>Node Status</h3>
<p className="stat-number">{nodeConnection?.isReachable ? 'Online' : 'Offline'}</p>
<p className="stat-description">Home server agent</p>
</div>
</div>
</div>
<div className="dashboard-sections">
<div className="section">
<h2>Recent Tunnels</h2>
<div className="tunnel-list">
{activeTunnels.slice(0, 5).map(tunnel => (
<div key={tunnel.id} className="tunnel-item">
<div className="tunnel-info">
<h4>{tunnel.name}</h4>
<p>{tunnel.protocol} {tunnel.localIp}:{tunnel.localPort} :{tunnel.remotePort}</p>
</div>
<div className="tunnel-status">
{tunnelStatuses?.find(s => s.id === tunnel.id)?.active ? (
<span className="status-badge status-active">Active</span>
) : (
<span className="status-badge status-inactive">Inactive</span>
)}
</div>
</div>
))}
{activeTunnels.length === 0 && (
<p className="empty-state">No active tunnels configured</p>
)}
</div>
</div>
<div className="section">
<h2>System Status</h2>
<div className="status-list">
<div className="status-item">
<div className="status-indicator">
{frpcStatus?.running ? (
<CheckCircle className="text-green-500" size={20} />
) : (
<XCircle className="text-red-500" size={20} />
)}
</div>
<div className="status-content">
<h4>FRPC Service</h4>
<p>{frpcStatus?.running ? 'Service is running normally' : 'Service is stopped'}</p>
</div>
</div>
<div className="status-item">
<div className="status-indicator">
<CheckCircle className="text-green-500" size={20} />
</div>
<div className="status-content">
<h4>API Server</h4>
<p>API is responding normally</p>
</div>
</div>
<div className="status-item">
<div className="status-indicator">
<CheckCircle className="text-green-500" size={20} />
</div>
<div className="status-content">
<h4>Database</h4>
<p>Database connection is healthy</p>
</div>
</div>
<div className="status-item">
<div className="status-indicator">
{nodeConnection?.isReachable ? (
<Wifi className="text-green-500" size={20} />
) : (
<WifiOff className="text-red-500" size={20} />
)}
</div>
<div className="status-content">
<h4>Home Server Node</h4>
<p>
{nodeConnection?.isReachable
? `Connected • Last seen: ${nodeConnection.lastConnectionTime ? new Date(nodeConnection.lastConnectionTime).toLocaleTimeString() : 'Now'}`
: 'Disconnected or unreachable'
}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,253 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { frpcApi } from '../api/client';
import { Server, Play, Square, RotateCcw, RefreshCw, FileText, Activity } from 'lucide-react';
import { toast } from 'react-hot-toast';
const ServerStatus: React.FC = () => {
const [logsLines, setLogsLines] = useState(50);
const queryClient = useQueryClient();
const { data: frpcStatus, isLoading: statusLoading } = useQuery({
queryKey: ['frpc-status'],
queryFn: frpcApi.getStatus,
refetchInterval: 3000,
});
const { data: logs, isLoading: logsLoading } = useQuery({
queryKey: ['frpc-logs', logsLines],
queryFn: () => frpcApi.getLogs(logsLines),
refetchInterval: 5000,
});
const startMutation = useMutation({
mutationFn: frpcApi.start,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
toast.success('FRPC service started');
},
onError: (error) => {
console.error('Start error:', error);
toast.error('Failed to start FRPC service');
},
});
const stopMutation = useMutation({
mutationFn: frpcApi.stop,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
toast.success('FRPC service stopped');
},
onError: (error) => {
console.error('Stop error:', error);
toast.error('Failed to stop FRPC service');
},
});
const restartMutation = useMutation({
mutationFn: frpcApi.restart,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
toast.success('FRPC service restarted');
},
onError: (error) => {
console.error('Restart error:', error);
toast.error('Failed to restart FRPC service');
},
});
const regenerateMutation = useMutation({
mutationFn: frpcApi.regenerate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['frpc-status'] });
toast.success('FRPC configuration regenerated');
},
onError: (error) => {
console.error('Regenerate error:', error);
toast.error('Failed to regenerate FRPC configuration');
},
});
const handleStart = () => startMutation.mutate();
const handleStop = () => stopMutation.mutate();
const handleRestart = () => restartMutation.mutate();
const handleRegenerate = () => regenerateMutation.mutate();
const isLoading = statusLoading || logsLoading;
const isAnyMutationPending =
startMutation.isPending ||
stopMutation.isPending ||
restartMutation.isPending ||
regenerateMutation.isPending;
return (
<div className="server-status">
<div className="server-header">
<h1>Server Status</h1>
<div className="server-info">
<div className="status-indicator">
<Activity
size={24}
className={frpcStatus?.running ? 'text-green-500' : 'text-red-500'}
/>
<span className={`status-text ${frpcStatus?.running ? 'text-green-500' : 'text-red-500'}`}>
{frpcStatus?.running ? 'Running' : 'Stopped'}
</span>
</div>
</div>
</div>
<div className="server-controls">
<div className="control-section">
<h2>Service Controls</h2>
<div className="control-buttons">
<button
className="btn btn-success"
onClick={handleStart}
disabled={isAnyMutationPending || frpcStatus?.running}
>
<Play size={18} />
Start
</button>
<button
className="btn btn-danger"
onClick={handleStop}
disabled={isAnyMutationPending || !frpcStatus?.running}
>
<Square size={18} />
Stop
</button>
<button
className="btn btn-warning"
onClick={handleRestart}
disabled={isAnyMutationPending}
>
<RotateCcw size={18} />
Restart
</button>
<button
className="btn btn-secondary"
onClick={handleRegenerate}
disabled={isAnyMutationPending}
>
<RefreshCw size={18} />
Regenerate Config
</button>
</div>
</div>
<div className="status-section">
<h2>Service Information</h2>
<div className="info-grid">
<div className="info-item">
<span className="info-label">Status:</span>
<span className={`info-value ${frpcStatus?.running ? 'text-green-500' : 'text-red-500'}`}>
{frpcStatus?.running ? 'Running' : 'Stopped'}
</span>
</div>
<div className="info-item">
<span className="info-label">Container:</span>
<span className="info-value">frpc</span>
</div>
<div className="info-item">
<span className="info-label">Last Updated:</span>
<span className="info-value">
{new Date().toLocaleString()}
</span>
</div>
</div>
</div>
</div>
<div className="logs-section">
<div className="logs-header">
<h2>
<FileText size={20} />
Service Logs
</h2>
<div className="logs-controls">
<select
value={logsLines}
onChange={(e) => setLogsLines(parseInt(e.target.value))}
className="logs-select"
>
<option value={25}>Last 25 lines</option>
<option value={50}>Last 50 lines</option>
<option value={100}>Last 100 lines</option>
<option value={200}>Last 200 lines</option>
</select>
<button
className="btn btn-sm btn-secondary"
onClick={() => queryClient.invalidateQueries({ queryKey: ['frpc-logs'] })}
disabled={logsLoading}
>
<RefreshCw size={16} />
Refresh
</button>
</div>
</div>
<div className="logs-container">
{logsLoading ? (
<div className="logs-loading">
<RefreshCw className="animate-spin" size={20} />
<span>Loading logs...</span>
</div>
) : (
<pre className="logs-content">
{logs?.logs || 'No logs available'}
</pre>
)}
</div>
</div>
<div className="system-info">
<h2>System Information</h2>
<div className="info-cards">
<div className="info-card">
<div className="info-card-header">
<Server size={24} />
<h3>FRPC Service</h3>
</div>
<div className="info-card-content">
<p>Fast Reverse Proxy Client for tunneling services</p>
<p className="info-detail">
Status: <span className={frpcStatus?.running ? 'text-green-500' : 'text-red-500'}>
{frpcStatus?.running ? 'Active' : 'Inactive'}
</span>
</p>
</div>
</div>
<div className="info-card">
<div className="info-card-header">
<Activity size={24} />
<h3>API Server</h3>
</div>
<div className="info-card-content">
<p>RESTful API for managing tunnel configurations</p>
<p className="info-detail">
Status: <span className="text-green-500">Running</span>
</p>
</div>
</div>
<div className="info-card">
<div className="info-card-header">
<FileText size={24} />
<h3>Configuration</h3>
</div>
<div className="info-card-content">
<p>Tunnel configurations stored in SQLite database</p>
<p className="info-detail">
Auto-generated FRPC config from active tunnels
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default ServerStatus;

View file

@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { tunnelApi, nodeApi, TunnelConfig } from '../api/client';
import { Plus, Edit2, Trash2, Power, PowerOff, Settings, Send, RefreshCw } from 'lucide-react';
import { toast } from 'react-hot-toast';
import TunnelForm from '../components/TunnelForm';
const TunnelManager: React.FC = () => {
const [showForm, setShowForm] = useState(false);
const [editingTunnel, setEditingTunnel] = useState<TunnelConfig | null>(null);
const queryClient = useQueryClient();
const { data: tunnels, isLoading } = useQuery({
queryKey: ['tunnels'],
queryFn: tunnelApi.getAllTunnels,
refetchInterval: 5000,
});
const { data: tunnelStatuses } = useQuery({
queryKey: ['tunnel-statuses'],
queryFn: tunnelApi.getAllTunnelStatuses,
refetchInterval: 10000,
});
const { data: nodeConnection } = useQuery({
queryKey: ['node-connection'],
queryFn: nodeApi.getConnection,
refetchInterval: 30000,
retry: false,
});
const deleteMutation = useMutation({
mutationFn: tunnelApi.deleteTunnel,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
toast.success('Tunnel deleted successfully');
},
onError: (error) => {
console.error('Delete error:', error);
toast.error('Failed to delete tunnel');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: string; updates: Partial<TunnelConfig> }) =>
tunnelApi.updateTunnel(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
toast.success('Tunnel updated successfully');
},
onError: (error) => {
console.error('Update error:', error);
toast.error('Failed to update tunnel');
},
});
const pushToNodeMutation = useMutation({
mutationFn: nodeApi.pushAndRestart,
onSuccess: (data) => {
toast.success(`Successfully pushed ${data.tunnelCount} tunnels to node and restarted frpc`);
queryClient.invalidateQueries({ queryKey: ['node-connection'] });
},
onError: (error: any) => {
console.error('Push to node error:', error);
toast.error(error.response?.data?.error || 'Failed to push configuration to node');
},
});
const handleDelete = (id: string) => {
if (window.confirm('Are you sure you want to delete this tunnel?')) {
deleteMutation.mutate(id);
}
};
const handleEdit = (tunnel: TunnelConfig) => {
setEditingTunnel(tunnel);
setShowForm(true);
};
const handleToggleEnabled = (tunnel: TunnelConfig) => {
updateMutation.mutate({
id: tunnel.id!,
updates: { enabled: !tunnel.enabled },
});
};
const handleFormClose = () => {
setShowForm(false);
setEditingTunnel(null);
};
const handleFormSubmit = () => {
handleFormClose();
queryClient.invalidateQueries({ queryKey: ['tunnels'] });
};
const handlePushToNode = () => {
if (window.confirm('Push current tunnel configuration to node and restart frpc?')) {
pushToNodeMutation.mutate();
}
};
if (isLoading) {
return (
<div className="tunnel-manager">
<div className="loading">Loading tunnels...</div>
</div>
);
}
return (
<div className="tunnel-manager">
<div className="tunnel-header">
<h1>Tunnel Manager</h1>
<div className="header-actions">
{nodeConnection && (
<div className="node-status">
<span className={`node-indicator ${nodeConnection.isReachable ? 'online' : 'offline'}`}>
Node: {nodeConnection.isReachable ? 'Online' : 'Offline'}
</span>
<button
className="btn btn-secondary"
onClick={handlePushToNode}
disabled={pushToNodeMutation.isPending || !nodeConnection.isReachable}
title="Push configuration to node and restart frpc"
>
{pushToNodeMutation.isPending ? (
<RefreshCw size={18} className="spinning" />
) : (
<Send size={18} />
)}
Push to Node
</button>
</div>
)}
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
<Plus size={18} />
Add Tunnel
</button>
</div>
</div>
<div className="tunnels-grid">
{tunnels?.map(tunnel => {
const status = tunnelStatuses?.find(s => s.id === tunnel.id);
return (
<div key={tunnel.id} className="tunnel-card">
<div className="tunnel-card-header">
<div className="tunnel-title">
<h3>{tunnel.name}</h3>
<div className="tunnel-badges">
<span className={`badge ${tunnel.enabled ? 'badge-success' : 'badge-secondary'}`}>
{tunnel.enabled ? 'Enabled' : 'Disabled'}
</span>
<span className={`badge ${status?.active ? 'badge-active' : 'badge-inactive'}`}>
{status?.active ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="tunnel-actions">
<button
className={`btn btn-sm ${tunnel.enabled ? 'btn-warning' : 'btn-success'}`}
onClick={() => handleToggleEnabled(tunnel)}
disabled={updateMutation.isPending}
>
{tunnel.enabled ? <PowerOff size={16} /> : <Power size={16} />}
</button>
<button
className="btn btn-sm btn-secondary"
onClick={() => handleEdit(tunnel)}
>
<Edit2 size={16} />
</button>
<button
className="btn btn-sm btn-danger"
onClick={() => handleDelete(tunnel.id!)}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="tunnel-info">
<div className="info-row">
<span className="info-label">Protocol:</span>
<span className="info-value">{tunnel.protocol}</span>
</div>
<div className="info-row">
<span className="info-label">Local:</span>
<span className="info-value">{tunnel.localIp}:{tunnel.localPort}</span>
</div>
<div className="info-row">
<span className="info-label">Remote:</span>
<span className="info-value">:{tunnel.remotePort}</span>
</div>
<div className="info-row">
<span className="info-label">Created:</span>
<span className="info-value">
{tunnel.createdAt ? new Date(tunnel.createdAt).toLocaleDateString() : 'N/A'}
</span>
</div>
</div>
{status?.error && (
<div className="tunnel-error">
<span className="error-text">Error: {status.error}</span>
</div>
)}
</div>
);
})}
</div>
{tunnels?.length === 0 && (
<div className="empty-state">
<Settings size={64} className="empty-icon" />
<h3>No tunnels configured</h3>
<p>Get started by adding your first tunnel configuration</p>
<button
className="btn btn-primary"
onClick={() => setShowForm(true)}
>
<Plus size={18} />
Add Your First Tunnel
</button>
</div>
)}
{showForm && (
<TunnelForm
tunnel={editingTunnel}
onClose={handleFormClose}
onSubmit={handleFormSubmit}
/>
)}
</div>
);
};
export default TunnelManager;

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx"
}
}

1
app/src/client/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

179
app/src/server/database.ts Normal file
View file

@ -0,0 +1,179 @@
import Database from 'better-sqlite3';
import { TunnelConfig, TunnelConfigSchema, TunnelConfigUpdate } from './types.js';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
export class TunnelDatabase {
private db: Database.Database;
constructor(dbPath: string = 'data/tunnels.db') {
this.db = new Database(dbPath);
this.initializeDatabase();
}
private initializeDatabase() {
// Create tunnels table
this.db.exec(`
CREATE TABLE IF NOT EXISTS tunnels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
protocol TEXT NOT NULL CHECK(protocol IN ('TCP', 'UDP')),
local_ip TEXT NOT NULL,
local_port INTEGER NOT NULL,
remote_port INTEGER NOT NULL,
enabled BOOLEAN DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Create trigger to update updated_at column
this.db.exec(`
CREATE TRIGGER IF NOT EXISTS update_tunnels_updated_at
AFTER UPDATE ON tunnels
BEGIN
UPDATE tunnels SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`);
}
// Get all tunnel configurations
getAllTunnels(): TunnelConfig[] {
const stmt = this.db.prepare(`
SELECT
id,
name,
protocol,
local_ip as localIp,
local_port as localPort,
remote_port as remotePort,
enabled,
created_at as createdAt,
updated_at as updatedAt
FROM tunnels
ORDER BY created_at DESC
`);
return stmt.all() as TunnelConfig[];
}
// Get tunnel by ID
getTunnelById(id: string): TunnelConfig | null {
const stmt = this.db.prepare(`
SELECT
id,
name,
protocol,
local_ip as localIp,
local_port as localPort,
remote_port as remotePort,
enabled,
created_at as createdAt,
updated_at as updatedAt
FROM tunnels
WHERE id = ?
`);
const result = stmt.get(id) as TunnelConfig | undefined;
return result || null;
}
// Create new tunnel configuration
createTunnel(config: Omit<TunnelConfig, 'id' | 'createdAt' | 'updatedAt'>): TunnelConfig {
const id = uuidv4();
const now = new Date().toISOString();
const stmt = this.db.prepare(`
INSERT INTO tunnels (id, name, protocol, local_ip, local_port, remote_port, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, config.name, config.protocol, config.localIp, config.localPort, config.remotePort, config.enabled);
return {
id,
...config,
createdAt: now,
updatedAt: now
};
}
// Update tunnel configuration
updateTunnel(id: string, updates: Partial<TunnelConfig>): TunnelConfig | null {
const existing = this.getTunnelById(id);
if (!existing) return null;
const fields = [];
const values = [];
if (updates.name !== undefined) {
fields.push('name = ?');
values.push(updates.name);
}
if (updates.protocol !== undefined) {
fields.push('protocol = ?');
values.push(updates.protocol);
}
if (updates.localIp !== undefined) {
fields.push('local_ip = ?');
values.push(updates.localIp);
}
if (updates.localPort !== undefined) {
fields.push('local_port = ?');
values.push(updates.localPort);
}
if (updates.remotePort !== undefined) {
fields.push('remote_port = ?');
values.push(updates.remotePort);
}
if (updates.enabled !== undefined) {
fields.push('enabled = ?');
values.push(updates.enabled);
}
if (fields.length === 0) return existing;
values.push(id);
const stmt = this.db.prepare(`
UPDATE tunnels
SET ${fields.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return this.getTunnelById(id);
}
// Delete tunnel configuration
deleteTunnel(id: string): boolean {
const stmt = this.db.prepare('DELETE FROM tunnels WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
// Get enabled tunnels only
getEnabledTunnels(): TunnelConfig[] {
const stmt = this.db.prepare(`
SELECT
id,
name,
protocol,
local_ip as localIp,
local_port as localPort,
remote_port as remotePort,
enabled,
created_at as createdAt,
updated_at as updatedAt
FROM tunnels
WHERE enabled = 1
ORDER BY created_at DESC
`);
return stmt.all() as TunnelConfig[];
}
// Close database connection
close() {
this.db.close();
}
}

View file

@ -0,0 +1,145 @@
import { TunnelConfig, FrpcConfig } from './types.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import { logger } from './logger.js';
const execAsync = promisify(exec);
export class FrpcManager {
private configPath: string;
private containerName: string;
constructor(configPath: string = 'data/frpc.toml', containerName: string = 'frpc') {
this.configPath = configPath;
this.containerName = containerName;
}
// Generate frpc.toml configuration from tunnel configs
async generateConfig(tunnels: TunnelConfig[], serverAddr: string, serverPort: number = 7000, token?: string): Promise<void> {
try {
const config: FrpcConfig = {
serverAddr,
serverPort,
token,
proxies: {}
};
// Add enabled tunnels to config
for (const tunnel of tunnels.filter(t => t.enabled)) {
config.proxies[tunnel.name] = {
type: tunnel.protocol.toLowerCase() as 'tcp' | 'udp',
localIP: tunnel.localIp,
localPort: tunnel.localPort,
remotePort: tunnel.remotePort
};
}
const tomlContent = this.generateTomlContent(config);
// Ensure directory exists
const dir = path.dirname(this.configPath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.configPath, tomlContent);
logger.info(`Generated frpc configuration with ${Object.keys(config.proxies).length} tunnels`);
} catch (error) {
logger.error('Failed to generate frpc configuration:', error);
throw error;
}
}
private generateTomlContent(config: FrpcConfig): string {
let toml = `[common]
server_addr = "${config.serverAddr}"
server_port = ${config.serverPort}
`;
if (config.token) {
toml += `token = "${config.token}"\n`;
}
toml += '\n';
// Add proxy configurations
for (const [name, proxy] of Object.entries(config.proxies)) {
toml += `[${name}]
type = "${proxy.type}"
local_ip = "${proxy.localIP}"
local_port = ${proxy.localPort}
remote_port = ${proxy.remotePort}
`;
}
return toml;
}
// Check if frpc container is running
async isRunning(): Promise<boolean> {
try {
const { stdout } = await execAsync(`docker ps --filter "name=${this.containerName}" --format "{{.Names}}"`);
return stdout.trim() === this.containerName;
} catch (error) {
logger.error('Failed to check frpc container status:', error);
return false;
}
}
// Start frpc container
async start(): Promise<void> {
try {
await execAsync(`docker start ${this.containerName}`);
logger.info(`Started frpc container: ${this.containerName}`);
} catch (error) {
logger.error('Failed to start frpc container:', error);
throw error;
}
}
// Stop frpc container
async stop(): Promise<void> {
try {
await execAsync(`docker stop ${this.containerName}`);
logger.info(`Stopped frpc container: ${this.containerName}`);
} catch (error) {
logger.error('Failed to stop frpc container:', error);
throw error;
}
}
// Restart frpc container
async restart(): Promise<void> {
try {
await execAsync(`docker restart ${this.containerName}`);
logger.info(`Restarted frpc container: ${this.containerName}`);
} catch (error) {
logger.error('Failed to restart frpc container:', error);
throw error;
}
}
// Get frpc container logs
async getLogs(lines: number = 50): Promise<string> {
try {
const { stdout } = await execAsync(`docker logs --tail ${lines} ${this.containerName}`);
return stdout;
} catch (error) {
logger.error('Failed to get frpc container logs:', error);
throw error;
}
}
// Check tunnel status by attempting to connect
async checkTunnelStatus(tunnel: TunnelConfig): Promise<boolean> {
try {
// This is a basic implementation - you might want to implement actual connectivity checks
// For now, we'll just check if the container is running
return await this.isRunning();
} catch (error) {
logger.error(`Failed to check tunnel status for ${tunnel.name}:`, error);
return false;
}
}
}

42
app/src/server/logger.ts Normal file
View file

@ -0,0 +1,42 @@
import winston from 'winston';
import path from 'path';
// Create logs directory if it doesn't exist
const logDir = 'logs';
export const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'frpc-manager' },
transports: [
// Write all logs with level 'error' and below to error.log
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error',
handleExceptions: true,
handleRejections: true
}),
// Write all logs with level 'info' and below to combined.log
new winston.transports.File({
filename: path.join(logDir, 'combined.log'),
handleExceptions: true,
handleRejections: true
}),
],
});
// If we're not in production, also log to console with simple format
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
export default logger;

55
app/src/server/main.ts Normal file
View file

@ -0,0 +1,55 @@
import express from "express";
import ViteExpress from "vite-express";
import cors from "cors";
import path from "path";
import { fileURLToPath } from "url";
import { logger } from "./logger.js";
import routes from "./routes.js";
const app = express();
// Get __dirname in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Logging middleware
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
method: req.method,
path: req.path,
userAgent: req.get('User-Agent'),
ip: req.ip
});
next();
});
// API routes
app.use("/api", routes);
// Health check endpoint
app.get("/health", (_, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Legacy hello endpoint
app.get("/hello", (_, res) => {
res.send("Hello Vite + React + TypeScript!");
});
// Error handling middleware
app.use((error: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', error);
res.status(500).json({ error: 'Internal server error' });
});
const PORT = parseInt(process.env.PORT || '3000');
ViteExpress.listen(app, PORT, () => {
logger.info(`Server is listening on port ${PORT}`);
console.log(`Server is listening on port ${PORT}...`);
});

View file

@ -0,0 +1,114 @@
import axios, { AxiosResponse } from 'axios';
import { logger } from './logger.js';
export interface NodeStatus {
status: string;
timestamp: string;
uptime?: number;
memory?: {
used: number;
total: number;
};
cpu?: {
usage: number;
};
}
export interface NodeConfig {
url: string;
token: string;
timeout?: number;
}
export class NodeClient {
private config: NodeConfig;
private lastConnectionTime: Date | null = null;
private isOnline: boolean = false;
constructor(config: NodeConfig) {
this.config = {
...config,
timeout: config.timeout || 5000,
};
}
private async makeRequest<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: string,
data?: any
): Promise<T> {
try {
const response: AxiosResponse<T> = await axios({
method,
url: `${this.config.url}${endpoint}`,
headers: {
'Authorization': `Bearer ${this.config.token}`,
'Content-Type': 'application/json',
},
data,
timeout: this.config.timeout,
});
this.lastConnectionTime = new Date();
this.isOnline = true;
return response.data;
} catch (error) {
this.isOnline = false;
logger.error(`Node request failed: ${method} ${endpoint}`, error);
throw error;
}
}
// Get node status
async getStatus(): Promise<NodeStatus> {
return this.makeRequest<NodeStatus>('GET', '/api/status');
}
// Send updated frpc.toml config to the node
async updateConfig(config: string): Promise<{ success: boolean; message: string }> {
return this.makeRequest<{ success: boolean; message: string }>('POST', '/api/frpc/update-config', {
config,
});
}
// Restart the FRP client on the node
async restartFrpc(): Promise<{ success: boolean; message: string }> {
return this.makeRequest<{ success: boolean; message: string }>('POST', '/api/frpc/restart');
}
// Check if node is reachable
async isReachable(): Promise<boolean> {
try {
await this.makeRequest<any>('GET', '/health');
return true;
} catch (error) {
return false;
}
}
// Get connection info
getConnectionInfo() {
return {
url: this.config.url,
isOnline: this.isOnline,
lastConnectionTime: this.lastConnectionTime,
};
}
}
// Factory function to create node client with environment variables
export function createNodeClient(): NodeClient {
const nodeUrl = process.env.NODE_URL;
const nodeToken = process.env.NODE_TOKEN;
if (!nodeUrl || !nodeToken) {
throw new Error('NODE_URL and NODE_TOKEN environment variables are required');
}
return new NodeClient({
url: nodeUrl,
token: nodeToken,
timeout: parseInt(process.env.NODE_TIMEOUT || '5000'),
});
}

376
app/src/server/routes.ts Normal file
View file

@ -0,0 +1,376 @@
import express, { Request, Response, NextFunction } from 'express';
import { TunnelDatabase } from './database.js';
import { FrpcManager } from './frpc-manager.js';
import { createNodeClient } from './node-client.js';
import { TunnelConfigSchema, TunnelConfigUpdateSchema, TunnelConfig, TunnelStatus } from './types.js';
import { logger } from './logger.js';
const router = express.Router();
// Initialize services
const db = new TunnelDatabase();
const frpcManager = new FrpcManager();
// Initialize node client if configured
let nodeClient: ReturnType<typeof createNodeClient> | null = null;
try {
nodeClient = createNodeClient();
logger.info('Node client initialized successfully');
} catch (error) {
logger.warn('Node client not configured:', error instanceof Error ? error.message : 'Unknown error');
}
// Async handler wrapper
const asyncHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// Get all tunnels
router.get('/tunnels', asyncHandler(async (req: Request, res: Response) => {
const tunnels = db.getAllTunnels();
res.json(tunnels);
}));
// Get tunnel by ID
router.get('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
const tunnel = db.getTunnelById(req.params.id);
if (!tunnel) {
return res.status(404).json({ error: 'Tunnel not found' });
}
res.json(tunnel);
}));
// Create new tunnel
router.post('/tunnels', asyncHandler(async (req: Request, res: Response) => {
const validation = TunnelConfigSchema.safeParse(req.body);
if (!validation.success) {
return res.status(400).json({
error: 'Invalid tunnel configuration',
details: validation.error.errors
});
}
const tunnel = db.createTunnel(validation.data);
// Regenerate frpc config if tunnel is enabled
if (tunnel.enabled) {
await regenerateFrpcConfig();
}
res.status(201).json(tunnel);
}));
// Update tunnel
router.put('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
const validation = TunnelConfigUpdateSchema.safeParse({
...req.body,
id: req.params.id
});
if (!validation.success) {
return res.status(400).json({
error: 'Invalid tunnel configuration',
details: validation.error.errors
});
}
const tunnel = db.updateTunnel(req.params.id, validation.data);
if (!tunnel) {
return res.status(404).json({ error: 'Tunnel not found' });
}
// Regenerate frpc config
await regenerateFrpcConfig();
res.json(tunnel);
}));
// Delete tunnel
router.delete('/tunnels/:id', asyncHandler(async (req: Request, res: Response) => {
const deleted = db.deleteTunnel(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Tunnel not found' });
}
// Regenerate frpc config
await regenerateFrpcConfig();
res.status(204).send();
}));
// Get tunnel status
router.get('/tunnels/:id/status', asyncHandler(async (req: Request, res: Response) => {
const tunnel = db.getTunnelById(req.params.id);
if (!tunnel) {
return res.status(404).json({ error: 'Tunnel not found' });
}
const active = await frpcManager.checkTunnelStatus(tunnel);
const status: TunnelStatus = {
id: tunnel.id!,
name: tunnel.name,
active,
lastChecked: new Date().toISOString()
};
res.json(status);
}));
// Get all tunnel statuses
router.get('/tunnels-status', asyncHandler(async (req: Request, res: Response) => {
const tunnels = db.getAllTunnels();
const statuses: TunnelStatus[] = [];
for (const tunnel of tunnels) {
try {
const active = await frpcManager.checkTunnelStatus(tunnel);
statuses.push({
id: tunnel.id!,
name: tunnel.name,
active,
lastChecked: new Date().toISOString()
});
} catch (error) {
statuses.push({
id: tunnel.id!,
name: tunnel.name,
active: false,
lastChecked: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
res.json(statuses);
}));
// Control frpc service
router.post('/frpc/:action', asyncHandler(async (req: Request, res: Response) => {
const { action } = req.params;
switch (action) {
case 'start':
await frpcManager.start();
break;
case 'stop':
await frpcManager.stop();
break;
case 'restart':
await frpcManager.restart();
break;
case 'regenerate':
await regenerateFrpcConfig();
break;
default:
return res.status(400).json({ error: 'Invalid action' });
}
res.json({ message: `frpc ${action} completed successfully` });
}));
// Get frpc status
router.get('/frpc/status', asyncHandler(async (req: Request, res: Response) => {
const running = await frpcManager.isRunning();
res.json({ running });
}));
// Get frpc logs
router.get('/frpc/logs', asyncHandler(async (req: Request, res: Response) => {
const lines = parseInt(req.query.lines as string) || 50;
const logs = await frpcManager.getLogs(lines);
res.json({ logs });
}));
// Helper function to regenerate frpc config and restart if needed
async function regenerateFrpcConfig() {
const enabledTunnels = db.getEnabledTunnels();
// Get server configuration from environment variables
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
const token = process.env.FRPC_TOKEN;
await frpcManager.generateConfig(enabledTunnels, serverAddr, serverPort, token);
// Restart frpc if it's running
if (await frpcManager.isRunning()) {
await frpcManager.restart();
}
}
// Node management endpoints
router.get('/node/status', asyncHandler(async (req: Request, res: Response) => {
if (!nodeClient) {
return res.status(503).json({ error: 'Node client not configured' });
}
try {
const status = await nodeClient.getStatus();
const connectionInfo = nodeClient.getConnectionInfo();
res.json({
...status,
connection: connectionInfo
});
} catch (error) {
logger.error('Failed to get node status:', error);
res.status(500).json({
error: 'Failed to connect to node',
connection: nodeClient.getConnectionInfo()
});
}
}));
router.get('/node/connection', asyncHandler(async (req: Request, res: Response) => {
if (!nodeClient) {
return res.status(503).json({ error: 'Node client not configured' });
}
const connectionInfo = nodeClient.getConnectionInfo();
const isReachable = await nodeClient.isReachable();
res.json({
...connectionInfo,
isReachable
});
}));
router.post('/node/push-config', asyncHandler(async (req: Request, res: Response) => {
if (!nodeClient) {
return res.status(503).json({ error: 'Node client not configured' });
}
try {
// Regenerate frpc config
await regenerateFrpcConfig();
// Read the generated config
const enabledTunnels = db.getEnabledTunnels();
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
const token = process.env.FRPC_TOKEN;
// Generate config content
let tomlContent = `[common]
server_addr = "${serverAddr}"
server_port = ${serverPort}
`;
if (token) {
tomlContent += `token = "${token}"\n`;
}
tomlContent += '\n';
// Add proxy configurations
for (const tunnel of enabledTunnels) {
tomlContent += `[${tunnel.name}]
type = "${tunnel.protocol.toLowerCase()}"
local_ip = "${tunnel.localIp}"
local_port = ${tunnel.localPort}
remote_port = ${tunnel.remotePort}
`;
}
// Send config to node
const result = await nodeClient.updateConfig(tomlContent);
logger.info('Configuration pushed to node successfully');
res.json({
message: 'Configuration pushed to node successfully',
tunnelCount: enabledTunnels.length,
nodeResponse: result
});
} catch (error) {
logger.error('Failed to push config to node:', error);
res.status(500).json({
error: 'Failed to push configuration to node',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
}));
router.post('/node/restart-frpc', asyncHandler(async (req: Request, res: Response) => {
if (!nodeClient) {
return res.status(503).json({ error: 'Node client not configured' });
}
try {
const result = await nodeClient.restartFrpc();
logger.info('frpc restarted on node successfully');
res.json({
message: 'frpc restarted on node successfully',
nodeResponse: result
});
} catch (error) {
logger.error('Failed to restart frpc on node:', error);
res.status(500).json({
error: 'Failed to restart frpc on node',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
}));
router.post('/node/push-and-restart', asyncHandler(async (req: Request, res: Response) => {
if (!nodeClient) {
return res.status(503).json({ error: 'Node client not configured' });
}
try {
// First push the config
await regenerateFrpcConfig();
// Read the generated config
const enabledTunnels = db.getEnabledTunnels();
const serverAddr = process.env.FRPC_SERVER_ADDR || 'your-vps-ip';
const serverPort = parseInt(process.env.FRPC_SERVER_PORT || '7000');
const token = process.env.FRPC_TOKEN;
// Generate config content
let tomlContent = `[common]
server_addr = "${serverAddr}"
server_port = ${serverPort}
`;
if (token) {
tomlContent += `token = "${token}"\n`;
}
tomlContent += '\n';
// Add proxy configurations
for (const tunnel of enabledTunnels) {
tomlContent += `[${tunnel.name}]
type = "${tunnel.protocol.toLowerCase()}"
local_ip = "${tunnel.localIp}"
local_port = ${tunnel.localPort}
remote_port = ${tunnel.remotePort}
`;
}
// Send config to node and restart in one call
const result = await nodeClient.updateConfig(tomlContent);
const restartResult = await nodeClient.restartFrpc();
logger.info('Configuration pushed and frpc restarted on node successfully');
res.json({
message: 'Configuration pushed and frpc restarted on node successfully',
tunnelCount: enabledTunnels.length,
configResponse: result,
restartResponse: restartResult
});
} catch (error) {
logger.error('Failed to push config and restart frpc on node:', error);
res.status(500).json({
error: 'Failed to push configuration and restart frpc on node',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
}));
export default router;

43
app/src/server/types.ts Normal file
View file

@ -0,0 +1,43 @@
import { z } from 'zod';
// Zod schemas for validation
export const TunnelConfigSchema = z.object({
id: z.string().optional(),
name: z.string().min(1, 'Name is required'),
protocol: z.enum(['TCP', 'UDP']),
localIp: z.string().min(1, 'Local IP is required'),
localPort: z.number().int().min(1).max(65535),
remotePort: z.number().int().min(1).max(65535),
enabled: z.boolean().default(true),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});
export const TunnelConfigUpdateSchema = TunnelConfigSchema.partial().extend({
id: z.string(),
});
export type TunnelConfig = z.infer<typeof TunnelConfigSchema>;
export type TunnelConfigUpdate = z.infer<typeof TunnelConfigUpdateSchema>;
export interface TunnelStatus {
id: string;
name: string;
active: boolean;
lastChecked: string;
error?: string;
}
export interface FrpcConfig {
serverAddr: string;
serverPort: number;
token?: string;
proxies: {
[key: string]: {
type: 'tcp' | 'udp';
localIP: string;
localPort: number;
remotePort: number;
};
};
}