Updated building backend, improved oruitng, indivudal ssh tunnel control.
This commit is contained in:
19
.github/workflows/docker-image.yml
vendored
19
.github/workflows/docker-image.yml
vendored
@@ -51,8 +51,9 @@ jobs:
|
|||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: /tmp/.buildx-cache
|
path: /tmp/.buildx-cache
|
||||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
|
${{ runner.os }}-buildx-${{ github.ref_name }}-
|
||||||
${{ runner.os }}-buildx-
|
${{ runner.os }}-buildx-
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
@@ -65,10 +66,14 @@ jobs:
|
|||||||
- name: Determine Docker image tag
|
- name: Determine Docker image tag
|
||||||
run: |
|
run: |
|
||||||
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
if [ "${{ github.event.inputs.tag_name }}" == "" ]; then
|
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
|
||||||
IMAGE_TAG="${{ github.ref_name }}-development-latest"
|
|
||||||
else
|
|
||||||
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
|
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
|
||||||
|
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
IMAGE_TAG="latest"
|
||||||
|
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
|
||||||
|
IMAGE_TAG="development-latest"
|
||||||
|
else
|
||||||
|
IMAGE_TAG="${{ github.ref_name }}-development-latest"
|
||||||
fi
|
fi
|
||||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
@@ -95,12 +100,6 @@ jobs:
|
|||||||
rm -rf /tmp/.buildx-cache
|
rm -rf /tmp/.buildx-cache
|
||||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||||
|
|
||||||
- name: Notify via ntfy
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \
|
|
||||||
https://ntfy.karmaa.site/termix-build
|
|
||||||
|
|
||||||
- name: Delete all untagged image versions
|
- name: Delete all untagged image versions
|
||||||
if: success()
|
if: success()
|
||||||
uses: quartx-analytics/ghcr-cleaner@v1
|
uses: quartx-analytics/ghcr-cleaner@v1
|
||||||
|
|||||||
@@ -19,7 +19,17 @@ COPY . .
|
|||||||
# Build frontend
|
# Build frontend
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Production dependencies
|
# Stage 3: Build backend TypeScript
|
||||||
|
FROM deps AS backend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build backend TypeScript to JavaScript
|
||||||
|
RUN npm run build:backend
|
||||||
|
|
||||||
|
# Stage 4: Production dependencies
|
||||||
FROM node:18-alpine AS production-deps
|
FROM node:18-alpine AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -30,7 +40,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci --only=production --ignore-scripts --force && \
|
RUN npm ci --only=production --ignore-scripts --force && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 4: Build native modules
|
# Stage 5: Build native modules
|
||||||
FROM node:18-alpine AS native-builder
|
FROM node:18-alpine AS native-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -41,13 +51,14 @@ RUN apk add --no-cache python3 make g++
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install only the native modules we need
|
# Install only the native modules we need
|
||||||
RUN npm ci --only=production bcrypt better-sqlite3 --force && \
|
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Final image
|
# Stage 6: Final image
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080
|
PORT=8080 \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
# Install dependencies in a single layer
|
# Install dependencies in a single layer
|
||||||
RUN apk add --no-cache nginx gettext su-exec && \
|
RUN apk add --no-cache nginx gettext su-exec && \
|
||||||
@@ -61,20 +72,24 @@ RUN chown -R nginx:nginx /usr/share/nginx/html
|
|||||||
|
|
||||||
# Setup backend
|
# Setup backend
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Copy production dependencies and native modules
|
# Copy production dependencies and native modules
|
||||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --from=native-builder /app/node_modules/bcrypt /app/node_modules/bcrypt
|
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
|
||||||
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
|
||||||
|
|
||||||
# Copy backend source
|
# Copy compiled backend JavaScript
|
||||||
COPY src/backend/ ./src/backend/
|
COPY --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
|
|
||||||
|
# Copy package.json for scripts
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
RUN chown -R node:node /app
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
# Expose ports
|
# Expose ports
|
||||||
EXPOSE ${PORT} 8081 8082 8083 8084 8085
|
EXPOSE ${PORT} 8081 8082 8083 8084
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
termix:
|
termix:
|
||||||
#image: ghcr.io/lukegus/termix:latest
|
#image: ghcr.io/lukegus/termix:latest
|
||||||
image: ghcr.io/lukegus/termix:dev-0.3-development-latest
|
image: ghcr.io/lukegus/termix:dev-1.0-development-latest
|
||||||
container_name: termix
|
container_name: termix
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ set -e
|
|||||||
export PORT=${PORT:-8080}
|
export PORT=${PORT:-8080}
|
||||||
echo "Configuring web UI to run on port: $PORT"
|
echo "Configuring web UI to run on port: $PORT"
|
||||||
|
|
||||||
|
# Configure nginx with the correct port
|
||||||
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
envsubst '${PORT}' < /etc/nginx/nginx.conf > /etc/nginx/nginx.conf.tmp
|
||||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Setup data directory
|
||||||
mkdir -p /app/data
|
mkdir -p /app/data
|
||||||
chown -R node:node /app/data
|
chown -R node:node /app/data
|
||||||
chmod 755 /app/data
|
chmod 755 /app/data
|
||||||
@@ -19,12 +21,14 @@ echo "Starting backend services..."
|
|||||||
cd /app
|
cd /app
|
||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
|
|
||||||
|
# Start the compiled TypeScript backend
|
||||||
if command -v su-exec > /dev/null 2>&1; then
|
if command -v su-exec > /dev/null 2>&1; then
|
||||||
su-exec node node src/backend/starter.cjs
|
su-exec node node dist/backend/starter.js
|
||||||
else
|
else
|
||||||
su -s /bin/sh node -c "node src/backend/starter.cjs"
|
su -s /bin/sh node -c "node dist/backend/starter.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "All services started"
|
echo "All services started"
|
||||||
|
|
||||||
|
# Keep container running
|
||||||
tail -f /dev/null
|
tail -f /dev/null
|
||||||
@@ -18,6 +18,15 @@ http {
|
|||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /ssh/db/ {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /ssh/websocket {
|
location /ssh/websocket {
|
||||||
proxy_pass http://127.0.0.1:8082;
|
proxy_pass http://127.0.0.1:8082;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -31,27 +40,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh_tunnel/websocket {
|
location /ssh/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:8083;
|
proxy_pass http://127.0.0.1:8083;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /config_editor/websocket {
|
location /ssh/config_editor/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:8084;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ interface SSHTunnelObjectProps {
|
|||||||
host: SSHHost;
|
host: SSHHost;
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelObject({
|
export function SSHTunnelObject({
|
||||||
@@ -68,6 +68,7 @@ export function SSHTunnelObject({
|
|||||||
tunnelActions,
|
tunnelActions,
|
||||||
onTunnelAction
|
onTunnelAction
|
||||||
}: SSHTunnelObjectProps): React.ReactElement {
|
}: SSHTunnelObjectProps): React.ReactElement {
|
||||||
|
|
||||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||||
@@ -220,32 +221,34 @@ export function SSHTunnelObject({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{tunnel.autoStart && (
|
{tunnel.autoStart && (
|
||||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||||
<Zap className="h-3 w-3 mr-1" />
|
<Zap className="h-3 w-3 mr-1" />
|
||||||
Auto
|
Auto
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{/* Action Button */}
|
{/* Action Buttons */}
|
||||||
{!isActionLoading && (
|
{!isActionLoading && (
|
||||||
<>
|
<div className="flex flex-col gap-1">
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
onClick={() => onTunnelAction('disconnect', host, tunnelIndex)}
|
||||||
className="h-8 px-3 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
|
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3 mr-1" />
|
<Square className="h-3 w-3 mr-1" />
|
||||||
Disconnect
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
) : isRetrying || isWaiting ? (
|
) : isRetrying || isWaiting ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
|
||||||
className="h-8 px-3 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50"
|
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3 mr-1" />
|
<X className="h-3 w-3 mr-1" />
|
||||||
Cancel
|
Cancel
|
||||||
@@ -256,20 +259,20 @@ export function SSHTunnelObject({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
onClick={() => onTunnelAction('connect', host, tunnelIndex)}
|
||||||
disabled={isConnecting || isDisconnecting}
|
disabled={isConnecting || isDisconnecting}
|
||||||
className="h-8 px-3 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
|
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||||
>
|
>
|
||||||
<Play className="h-3 w-3 mr-1" />
|
<Play className="h-3 w-3 mr-1" />
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isActionLoading && (
|
{isActionLoading && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled
|
disabled
|
||||||
className="h-8 px-3 text-muted-foreground border-border"
|
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface SSHTunnelViewerProps {
|
|||||||
hosts: SSHHost[];
|
hosts: SSHHost[];
|
||||||
tunnelStatuses: Record<string, TunnelStatus>;
|
tunnelStatuses: Record<string, TunnelStatus>;
|
||||||
tunnelActions: Record<string, boolean>;
|
tunnelActions: Record<string, boolean>;
|
||||||
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<void>;
|
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SSHTunnelViewer({
|
export function SSHTunnelViewer({
|
||||||
|
|||||||
@@ -83,50 +83,59 @@ interface TunnelStatus {
|
|||||||
|
|
||||||
// Determine the base URL based on environment
|
// Determine the base URL based on environment
|
||||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
const baseURL = isLocalhost ? 'http://localhost:8081' : window.location.origin;
|
|
||||||
|
|
||||||
// Create axios instance with base configuration
|
// Create separate axios instances for different services
|
||||||
const api = axios.create({
|
const sshHostApi = axios.create({
|
||||||
baseURL,
|
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create tunnel API instance
|
|
||||||
const tunnelApi = axios.create({
|
const tunnelApi = axios.create({
|
||||||
|
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const configEditorApi = axios.create({
|
||||||
|
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function getCookie(name: string): string | undefined {
|
function getCookie(name: string): string | undefined {
|
||||||
const value = `; ${document.cookie}`;
|
const value = `; ${document.cookie}`;
|
||||||
const parts = value.split(`; ${name}=`);
|
const parts = value.split(`; ${name}=`);
|
||||||
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
if (parts.length === 2) return parts.pop()?.split(';').shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add request interceptor to include JWT token
|
// Add request interceptor to include JWT token for SSH Host API
|
||||||
api.interceptors.request.use((config) => {
|
sshHostApi.interceptors.request.use((config) => {
|
||||||
const token = getCookie('jwt'); // Adjust based on your token storage
|
const token = getCookie('jwt');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add request interceptor to include JWT token for Tunnel API
|
||||||
tunnelApi.interceptors.request.use((config) => {
|
tunnelApi.interceptors.request.use((config) => {
|
||||||
const token = getCookie('jwt'); // Adjust based on your token storage
|
const token = getCookie('jwt');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Host-related functions (use port 8081 for localhost)
|
||||||
|
|
||||||
// Get all SSH hosts
|
// Get all SSH hosts
|
||||||
export async function getSSHHosts(): Promise<SSHHost[]> {
|
export async function getSSHHosts(): Promise<SSHHost[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/ssh/host');
|
const response = await sshHostApi.get('/ssh/db/host');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching SSH hosts:', error);
|
console.error('Error fetching SSH hosts:', error);
|
||||||
@@ -181,7 +190,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
// Submit with FormData
|
// Submit with FormData
|
||||||
const response = await api.post('/ssh/host', formData, {
|
const response = await sshHostApi.post('/ssh/db/host', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -190,7 +199,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
// Submit with JSON
|
// Submit with JSON
|
||||||
const response = await api.post('/ssh/host', submitData);
|
const response = await sshHostApi.post('/ssh/db/host', submitData);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -239,7 +248,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
|||||||
delete dataWithoutFile.key;
|
delete dataWithoutFile.key;
|
||||||
formData.append('data', JSON.stringify(dataWithoutFile));
|
formData.append('data', JSON.stringify(dataWithoutFile));
|
||||||
|
|
||||||
const response = await api.put(`/ssh/host/${hostId}`, formData, {
|
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -247,7 +256,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} else {
|
} else {
|
||||||
const response = await api.put(`/ssh/host/${hostId}`, submitData);
|
const response = await sshHostApi.put(`/ssh/db/host/${hostId}`, submitData);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -259,7 +268,7 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
|||||||
// Delete SSH host
|
// Delete SSH host
|
||||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await api.delete(`/ssh/host/${hostId}`);
|
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting SSH host:', error);
|
console.error('Error deleting SSH host:', error);
|
||||||
@@ -270,7 +279,7 @@ export async function deleteSSHHost(hostId: number): Promise<any> {
|
|||||||
// Get SSH host by ID
|
// Get SSH host by ID
|
||||||
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/ssh/host/${hostId}`);
|
const response = await sshHostApi.get(`/ssh/db/host/${hostId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching SSH host:', error);
|
console.error('Error fetching SSH host:', error);
|
||||||
@@ -278,14 +287,12 @@ export async function getSSHHostById(hostId: number): Promise<SSHHost> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tunnel-related functions
|
// Tunnel-related functions (use port 8083 for localhost)
|
||||||
|
|
||||||
// Get all tunnel statuses (per-tunnel)
|
// Get all tunnel statuses (per-tunnel)
|
||||||
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
export async function getTunnelStatuses(): Promise<Record<string, TunnelStatus>> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
const response = await tunnelApi.get('/ssh/tunnel/status');
|
||||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/status' : `${baseURL}/ssh_tunnel/status`;
|
|
||||||
const response = await tunnelApi.get(tunnelUrl);
|
|
||||||
return response.data || {};
|
return response.data || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching tunnel statuses:', error);
|
console.error('Error fetching tunnel statuses:', error);
|
||||||
@@ -302,9 +309,7 @@ export async function getTunnelStatusByName(tunnelName: string): Promise<TunnelS
|
|||||||
// Connect tunnel (per-tunnel)
|
// Connect tunnel (per-tunnel)
|
||||||
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
const response = await tunnelApi.post('/ssh/tunnel/connect', tunnelConfig);
|
||||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/connect' : `${baseURL}/ssh_tunnel/connect`;
|
|
||||||
const response = await tunnelApi.post(tunnelUrl, tunnelConfig);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error connecting tunnel:', error);
|
console.error('Error connecting tunnel:', error);
|
||||||
@@ -315,9 +320,7 @@ export async function connectTunnel(tunnelConfig: TunnelConfig): Promise<any> {
|
|||||||
// Disconnect tunnel (per-tunnel)
|
// Disconnect tunnel (per-tunnel)
|
||||||
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
const response = await tunnelApi.post('/ssh/tunnel/disconnect', { tunnelName });
|
||||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/disconnect' : `${baseURL}/ssh_tunnel/disconnect`;
|
|
||||||
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error disconnecting tunnel:', error);
|
console.error('Error disconnecting tunnel:', error);
|
||||||
@@ -327,9 +330,7 @@ export async function disconnectTunnel(tunnelName: string): Promise<any> {
|
|||||||
|
|
||||||
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
export async function cancelTunnel(tunnelName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Determine the tunnel API URL based on environment
|
const response = await tunnelApi.post('/ssh/tunnel/cancel', { tunnelName });
|
||||||
const tunnelUrl = isLocalhost ? 'http://localhost:8083/cancel' : `${baseURL}/ssh_tunnel/cancel`;
|
|
||||||
const response = await tunnelApi.post(tunnelUrl, { tunnelName });
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error canceling tunnel:', error);
|
console.error('Error canceling tunnel:', error);
|
||||||
@@ -337,4 +338,6 @@ export async function cancelTunnel(tunnelName: string): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { api };
|
// Config-related functions (use port 8084 for localhost)
|
||||||
|
|
||||||
|
export { sshHostApi, tunnelApi, configEditorApi };
|
||||||
@@ -6,11 +6,9 @@ import { Client as SSHClient } from 'ssh2';
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 8084;
|
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: 'http://localhost:5173',
|
origin: '*',
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization']
|
allowedHeaders: ['Content-Type', 'Authorization']
|
||||||
}));
|
}));
|
||||||
@@ -287,4 +285,5 @@ process.on('SIGTERM', () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PORT = 8084;
|
||||||
app.listen(PORT, () => {});
|
app.listen(PORT, () => {});
|
||||||
@@ -31,12 +31,14 @@ const logger = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dbDir = path.resolve('./db/data');
|
const dataDir = process.env.DATA_DIR || './db/data';
|
||||||
|
const dbDir = path.resolve(dataDir);
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sqlite = new Database('./db/data/db.sqlite');
|
const dbPath = path.join(dataDir, 'db.sqlite');
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
// Create tables using Drizzle schema
|
// Create tables using Drizzle schema
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ function isLocalhost(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Internal-only endpoint for autostart (no JWT)
|
// Internal-only endpoint for autostart (no JWT)
|
||||||
router.get('/host/internal', async (req: Request, res: Response) => {
|
router.get('/db/host/internal', async (req: Request, res: Response) => {
|
||||||
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
|
if (!isLocalhost(req) && req.headers['x-internal-request'] !== '1') {
|
||||||
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
|
logger.warn('Unauthorized attempt to access internal SSH host endpoint');
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
@@ -116,7 +116,7 @@ router.get('/host/internal', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Route: Create SSH data (requires JWT)
|
// Route: Create SSH data (requires JWT)
|
||||||
// POST /ssh/host
|
// POST /ssh/host
|
||||||
router.post('/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||||
let hostData: any;
|
let hostData: any;
|
||||||
|
|
||||||
// Check if this is a multipart form data request (file upload)
|
// Check if this is a multipart form data request (file upload)
|
||||||
@@ -191,7 +191,7 @@ router.post('/host', authenticateJWT, upload.single('key'), async (req: Request,
|
|||||||
|
|
||||||
// Route: Update SSH data (requires JWT)
|
// Route: Update SSH data (requires JWT)
|
||||||
// PUT /ssh/host/:id
|
// PUT /ssh/host/:id
|
||||||
router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
router.put('/db/host/:id', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||||
let hostData: any;
|
let hostData: any;
|
||||||
|
|
||||||
// Check if this is a multipart form data request (file upload)
|
// Check if this is a multipart form data request (file upload)
|
||||||
@@ -268,7 +268,7 @@ router.put('/host/:id', authenticateJWT, upload.single('key'), async (req: Reque
|
|||||||
|
|
||||||
// Route: Get SSH data for the authenticated user (requires JWT)
|
// Route: Get SSH data for the authenticated user (requires JWT)
|
||||||
// GET /ssh/host
|
// GET /ssh/host
|
||||||
router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/db/host', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
logger.warn('Invalid userId for SSH data fetch');
|
logger.warn('Invalid userId for SSH data fetch');
|
||||||
@@ -298,7 +298,7 @@ router.get('/host', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Route: Get SSH host by ID (requires JWT)
|
// Route: Get SSH host by ID (requires JWT)
|
||||||
// GET /ssh/host/:id
|
// GET /ssh/host/:id
|
||||||
router.get('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ router.get('/host/:id', authenticateJWT, async (req: Request, res: Response) =>
|
|||||||
|
|
||||||
// Route: Get all unique folders for the authenticated user (requires JWT)
|
// Route: Get all unique folders for the authenticated user (requires JWT)
|
||||||
// GET /ssh/folders
|
// GET /ssh/folders
|
||||||
router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
if (!isNonEmptyString(userId)) {
|
if (!isNonEmptyString(userId)) {
|
||||||
logger.warn('Invalid userId for SSH folder fetch');
|
logger.warn('Invalid userId for SSH folder fetch');
|
||||||
@@ -367,7 +367,7 @@ router.get('/folders', authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Route: Delete SSH host by id (requires JWT)
|
// Route: Delete SSH host by id (requires JWT)
|
||||||
// DELETE /ssh/host/:id
|
// DELETE /ssh/host/:id
|
||||||
router.delete('/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as any).userId;
|
const userId = (req as any).userId;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!isNonEmptyString(userId) || !id) {
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const verificationTimers = new Map<string, NodeJS.Timeout>(); // timer keys -> t
|
|||||||
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
const activeRetryTimers = new Map<string, NodeJS.Timeout>(); // tunnelName -> retry timer
|
||||||
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
const countdownIntervals = new Map<string, NodeJS.Timeout>(); // tunnelName -> countdown interval
|
||||||
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
const retryExhaustedTunnels = new Set<string>(); // tunnelNames
|
||||||
const remoteClosureEvents = new Map<string, number>(); // tunnelName -> count
|
|
||||||
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
const tunnelConfigs = new Map<string, TunnelConfig>(); // tunnelName -> tunnelConfig
|
||||||
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
const activeTunnelProcesses = new Map<string, ChildProcess>(); // tunnelName -> ChildProcess
|
||||||
|
|
||||||
@@ -129,7 +129,6 @@ interface TunnelStatus {
|
|||||||
errorType?: ErrorType;
|
errorType?: ErrorType;
|
||||||
manualDisconnect?: boolean;
|
manualDisconnect?: boolean;
|
||||||
retryExhausted?: boolean;
|
retryExhausted?: boolean;
|
||||||
isRemoteRetry?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerificationData {
|
interface VerificationData {
|
||||||
@@ -299,7 +298,6 @@ function cleanupTunnelResources(tunnelName: string): void {
|
|||||||
function resetRetryState(tunnelName: string): void {
|
function resetRetryState(tunnelName: string): void {
|
||||||
retryCounters.delete(tunnelName);
|
retryCounters.delete(tunnelName);
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
remoteClosureEvents.delete(tunnelName);
|
|
||||||
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
if (activeRetryTimers.has(tunnelName)) {
|
||||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||||
@@ -320,7 +318,7 @@ function resetRetryState(tunnelName: string): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, isRemoteClosure = false): void {
|
function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true): void {
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
if (tunnelVerifications.has(tunnelName)) {
|
||||||
try {
|
try {
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
const verification = tunnelVerifications.get(tunnelName);
|
||||||
@@ -344,24 +342,7 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRemoteClosure) {
|
|
||||||
const currentCount = remoteClosureEvents.get(tunnelName) || 0;
|
|
||||||
remoteClosureEvents.set(tunnelName, currentCount + 1);
|
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: "Remote host disconnected"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentCount === 0) {
|
|
||||||
retryCounters.delete(tunnelName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRemoteClosure && retryExhaustedTunnels.has(tunnelName)) {
|
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryExhaustedTunnels.has(tunnelName)) {
|
if (retryExhaustedTunnels.has(tunnelName)) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -380,15 +361,6 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
const maxRetries = tunnelConfig.maxRetries || 3;
|
const maxRetries = tunnelConfig.maxRetries || 3;
|
||||||
const retryInterval = tunnelConfig.retryInterval || 5000;
|
const retryInterval = tunnelConfig.retryInterval || 5000;
|
||||||
|
|
||||||
if (isRemoteClosure) {
|
|
||||||
const currentCount = remoteClosureEvents.get(tunnelName) || 0;
|
|
||||||
remoteClosureEvents.set(tunnelName, currentCount + 1);
|
|
||||||
|
|
||||||
if (currentCount === 0) {
|
|
||||||
retryCounters.delete(tunnelName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
let retryCount = (retryCounters.get(tunnelName) || 0) + 1;
|
||||||
|
|
||||||
if (retryCount > maxRetries) {
|
if (retryCount > maxRetries) {
|
||||||
@@ -523,8 +495,12 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
|||||||
setupPingInterval(tunnelName, tunnelConfig);
|
setupPingInterval(tunnelName, tunnelConfig);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(`Verification failed for '${tunnelName}': ${failureReason}`);
|
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
||||||
|
|
||||||
|
// With the new verification approach, we're testing connectivity to the endpoint machine
|
||||||
|
// A failure might just mean the service isn't running on that port, not that the tunnel is broken
|
||||||
|
// Only disconnect if it's a critical error (command failed, connection error, or timeout)
|
||||||
|
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -532,42 +508,74 @@ function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig,
|
|||||||
reason: failureReason
|
reason: failureReason
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
|
} else {
|
||||||
|
// For connection refused or other non-critical errors, assume the tunnel is working
|
||||||
|
// The service might just not be running on the target port
|
||||||
|
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
||||||
|
cleanupVerification(true); // Treat as successful to prevent disconnect
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptVerification() {
|
function attemptVerification() {
|
||||||
const testCmd = `nc -z localhost ${tunnelConfig.sourcePort}`;
|
// Test the actual tunnel by trying to connect to the endpoint port
|
||||||
|
// This verifies that the tunnel is actually working
|
||||||
|
// With -R forwarding, the endpointPort should be listening on the endpoint machine
|
||||||
|
// We need to check if the port is accessible from the source machine to the endpoint machine
|
||||||
|
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
||||||
|
|
||||||
verificationConn.exec(testCmd, (err, stream) => {
|
verificationConn.exec(testCmd, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
||||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
|
let errorOutput = '';
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
stream.on('data', (data: Buffer) => {
|
||||||
output += data.toString();
|
output += data.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stream.stderr?.on('data', (data: Buffer) => {
|
||||||
|
errorOutput += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
stream.on('close', (code: number) => {
|
||||||
if (code === 0 && code !== undefined) {
|
logger.debug(`Verification for '${tunnelName}' completed with code ${code}, output: '${output}', error: '${errorOutput}'`);
|
||||||
|
if (code === 0) {
|
||||||
cleanupVerification(true);
|
cleanupVerification(true);
|
||||||
} else {
|
} else {
|
||||||
cleanupVerification(false, `Port ${tunnelConfig.sourcePort} is not accessible`);
|
// Check if it's a timeout or connection refused
|
||||||
|
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
||||||
|
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
||||||
|
|
||||||
|
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||||
|
if (isTimeout) {
|
||||||
|
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
||||||
|
} else if (isConnectionRefused) {
|
||||||
|
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupVerification(false, failureReason);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
stream.on('error', (err: Error) => {
|
||||||
|
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
||||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
verificationConn.on('ready', () => {
|
verificationConn.on('ready', () => {
|
||||||
|
// Add a small delay to allow the tunnel to fully establish
|
||||||
|
setTimeout(() => {
|
||||||
attemptVerification();
|
attemptVerification();
|
||||||
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
verificationConn.on('error', (err: Error) => {
|
verificationConn.on('error', (err: Error) => {
|
||||||
@@ -724,19 +732,15 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
if (retryAttempt === 0) {
|
if (retryAttempt === 0) {
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
retryCounters.delete(tunnelName);
|
retryCounters.delete(tunnelName);
|
||||||
remoteClosureEvents.delete(tunnelName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRetryAfterRemoteClosure = remoteClosureEvents.get(tunnelName) && retryAttempt > 0;
|
|
||||||
|
|
||||||
// Only set status to CONNECTING if we're not already in WAITING state
|
// Only set status to CONNECTING if we're not already in WAITING state
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
|
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||||
isRemoteRetry: !!isRetryAfterRemoteClosure
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,9 +784,6 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorType = classifyError(err.message);
|
const errorType = classifyError(err.message);
|
||||||
const isRemoteHostClosure = err.message.toLowerCase().includes("closed by remote host") ||
|
|
||||||
err.message.toLowerCase().includes("connection reset by peer") ||
|
|
||||||
err.message.toLowerCase().includes("broken pipe");
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
if (!manualDisconnects.has(tunnelName)) {
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
@@ -795,18 +796,12 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
activeTunnels.delete(tunnelName);
|
||||||
|
|
||||||
if (isRemoteHostClosure && retryExhaustedTunnels.has(tunnelName)) {
|
const shouldNotRetry = errorType === ERROR_TYPES.AUTH ||
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldNotRetry = !isRemoteHostClosure && (
|
|
||||||
errorType === ERROR_TYPES.AUTH ||
|
|
||||||
errorType === ERROR_TYPES.PORT ||
|
errorType === ERROR_TYPES.PORT ||
|
||||||
errorType === ERROR_TYPES.PERMISSION ||
|
errorType === ERROR_TYPES.PERMISSION ||
|
||||||
manualDisconnects.has(tunnelName)
|
manualDisconnects.has(tunnelName);
|
||||||
);
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
|
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry);
|
||||||
});
|
});
|
||||||
|
|
||||||
conn.on("close", () => {
|
conn.on("close", () => {
|
||||||
@@ -843,9 +838,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
if (tunnelConfig.endpointAuthMethod === "key" && tunnelConfig.endpointSSHKey) {
|
||||||
// For SSH key authentication, we need to create a temporary key file
|
// For SSH key authentication, we need to create a temporary key file
|
||||||
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||||
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
tunnelCmd = `echo '${tunnelConfig.endpointSSHKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker} && rm -f ${keyFilePath}`;
|
||||||
} else {
|
} else {
|
||||||
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -L ${tunnelConfig.sourcePort}:localhost:${tunnelConfig.endpointPort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
|
tunnelCmd = `sshpass -p '${tunnelConfig.endpointPassword || ''}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R ${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} ${tunnelMarker}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.exec(tunnelCmd, (err, stream) => {
|
conn.exec(tunnelCmd, (err, stream) => {
|
||||||
@@ -913,11 +908,11 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) {
|
if (!activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName)) {
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), isLikelyRemoteClosure);
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
} else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
|
} else if (retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure) {
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
retryExhaustedTunnels.delete(tunnelName);
|
||||||
retryCounters.delete(tunnelName);
|
retryCounters.delete(tunnelName);
|
||||||
handleDisconnect(tunnelName, tunnelConfig, true, true);
|
handleDisconnect(tunnelName, tunnelConfig, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -931,53 +926,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
|
|
||||||
stream.stderr.on("data", (data) => {
|
stream.stderr.on("data", (data) => {
|
||||||
const errorMsg = data.toString().trim();
|
const errorMsg = data.toString().trim();
|
||||||
|
logger.debug(`Tunnel stderr for '${tunnelName}': ${errorMsg}`);
|
||||||
const isNonRetryableError = errorMsg.includes("Permission denied") ||
|
|
||||||
errorMsg.includes("Authentication failed") ||
|
|
||||||
errorMsg.includes("failed for listen port") ||
|
|
||||||
errorMsg.includes("address already in use") ||
|
|
||||||
errorMsg.includes("bind: Address already in use") ||
|
|
||||||
errorMsg.includes("channel 0: open failed") ||
|
|
||||||
errorMsg.includes("remote port forwarding failed");
|
|
||||||
|
|
||||||
const isRemoteHostClosure = errorMsg.includes("closed by remote host") ||
|
|
||||||
errorMsg.includes("connection reset by peer") ||
|
|
||||||
errorMsg.includes("broken pipe");
|
|
||||||
|
|
||||||
if (isNonRetryableError || isRemoteHostClosure) {
|
|
||||||
if (activeRetryTimers.has(tunnelName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (retryExhaustedTunnels.has(tunnelName)) {
|
|
||||||
if (isRemoteHostClosure) {
|
|
||||||
retryExhaustedTunnels.delete(tunnelName);
|
|
||||||
retryCounters.delete(tunnelName);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
errorType: classifyError(errorMsg),
|
|
||||||
reason: errorMsg
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorType = classifyError(errorMsg);
|
|
||||||
const shouldNotRetry = !isRemoteHostClosure && (
|
|
||||||
errorType === ERROR_TYPES.AUTH ||
|
|
||||||
errorType === ERROR_TYPES.PORT ||
|
|
||||||
errorType === ERROR_TYPES.PERMISSION
|
|
||||||
);
|
|
||||||
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry, isRemoteHostClosure);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1071,8 +1020,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined,
|
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||||
isRemoteRetry: !!isRetryAfterRemoteClosure
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,11 +1130,11 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Express API endpoints
|
// Express API endpoints
|
||||||
app.get('/status', (req, res) => {
|
app.get('/ssh/tunnel/status', (req, res) => {
|
||||||
res.json(getAllTunnelStatus());
|
res.json(getAllTunnelStatus());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/status/:tunnelName', (req, res) => {
|
app.get('/ssh/tunnel/status/:tunnelName', (req, res) => {
|
||||||
const {tunnelName} = req.params;
|
const {tunnelName} = req.params;
|
||||||
const status = connectionStatus.get(tunnelName);
|
const status = connectionStatus.get(tunnelName);
|
||||||
|
|
||||||
@@ -1197,7 +1145,7 @@ app.get('/status/:tunnelName', (req, res) => {
|
|||||||
res.json({name: tunnelName, status});
|
res.json({name: tunnelName, status});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/connect', (req, res) => {
|
app.post('/ssh/tunnel/connect', (req, res) => {
|
||||||
const tunnelConfig: TunnelConfig = req.body;
|
const tunnelConfig: TunnelConfig = req.body;
|
||||||
|
|
||||||
if (!tunnelConfig || !tunnelConfig.name) {
|
if (!tunnelConfig || !tunnelConfig.name) {
|
||||||
@@ -1221,7 +1169,7 @@ app.post('/connect', (req, res) => {
|
|||||||
res.json({message: 'Connection request received', tunnelName});
|
res.json({message: 'Connection request received', tunnelName});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/disconnect', (req, res) => {
|
app.post('/ssh/tunnel/disconnect', (req, res) => {
|
||||||
const {tunnelName} = req.body;
|
const {tunnelName} = req.body;
|
||||||
|
|
||||||
if (!tunnelName) {
|
if (!tunnelName) {
|
||||||
@@ -1254,7 +1202,7 @@ app.post('/disconnect', (req, res) => {
|
|||||||
res.json({message: 'Disconnect request received', tunnelName});
|
res.json({message: 'Disconnect request received', tunnelName});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/cancel', (req, res) => {
|
app.post('/ssh/tunnel/cancel', (req, res) => {
|
||||||
const {tunnelName} = req.body;
|
const {tunnelName} = req.body;
|
||||||
|
|
||||||
if (!tunnelName) {
|
if (!tunnelName) {
|
||||||
@@ -1298,7 +1246,7 @@ app.post('/cancel', (req, res) => {
|
|||||||
async function initializeAutoStartTunnels(): Promise<void> {
|
async function initializeAutoStartTunnels(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
|
// Fetch hosts with auto-start tunnel connections from the new internal endpoint
|
||||||
const response = await axios.get('http://localhost:8081/ssh/host/internal', {
|
const response = await axios.get('http://localhost:8081/ssh/db/host/internal', {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Internal-Request': '1'
|
'X-Internal-Request': '1'
|
||||||
|
|||||||
Reference in New Issue
Block a user