Compare commits
31 Commits
aa83021393
..
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 07c9da8a80 | |||
| 4bdd4c1d8a | |||
| 336334c7f5 | |||
| a502ae2687 | |||
| 782d8446d9 | |||
| 5b79f5fb4b | |||
| a0f84ec78e | |||
| da996705c9 | |||
| 6d341309f1 | |||
| 187a3c7882 | |||
| c7ffdf81c2 | |||
| 0664d8763d | |||
| 853e06456e | |||
| cae073728a | |||
| 0f35d5bd6f | |||
| 1d6cce88a8 | |||
| 18e256baee | |||
| 7682a94981 | |||
| bb724377fe | |||
| 22a3dc7cbe | |||
| 6069f5395a | |||
| d542264567 | |||
| 7f717684eb | |||
| 90103f79c8 | |||
| 20db4742a7 | |||
| ca8b5dea7f | |||
| ca3d27e7f9 | |||
| 035f871b00 | |||
| 5bba2947c4 | |||
| 4e77b8f412 | |||
| 28a121d7f4 |
@@ -1,5 +1,10 @@
|
||||
FROM mcr.microsoft.com/devcontainers/python:3.14
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
@@ -4,3 +4,4 @@ __pycache__
|
||||
*.pyc
|
||||
.env
|
||||
frontend/node_modules
|
||||
img/
|
||||
+4
-1
@@ -26,4 +26,7 @@ MYSQL_POOL_SIZE=5
|
||||
MAX_CONCURRENT_SSH=32
|
||||
|
||||
# Paramiko SSH keepalive interval (seconds); set 0 to disable.
|
||||
SSH_KEEPALIVE_INTERVAL=30
|
||||
SSH_KEEPALIVE_INTERVAL=15
|
||||
|
||||
# WebSocket keepalive interval (seconds); server sends traffic to avoid proxy idle timeouts.
|
||||
WS_KEEPALIVE_INTERVAL=25
|
||||
@@ -1,21 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/public/ssh:latest .
|
||||
docker push cr.jdbnet.co.uk/public/ssh:latest
|
||||
@@ -0,0 +1,46 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker build -t cr.jdbnet.co.uk/public/ssh:dev .
|
||||
docker push cr.jdbnet.co.uk/public/ssh:dev
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Valid Project Key
|
||||
id: sonar_setup
|
||||
run: |
|
||||
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
|
||||
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: SonarQube Scan
|
||||
uses: sonarsource/sonarqube-scan-action@master
|
||||
continue-on-error: true
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
|
||||
-Dsonar.projectName=${{ gitea.repository }}
|
||||
-Dsonar.qualitygate.wait=true
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
|
||||
runs-on: build-htz-01
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract Version
|
||||
id: get_version
|
||||
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
|
||||
with:
|
||||
myToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
VERSION=${{ steps.get_version.outputs.VERSION }}
|
||||
docker build -t cr.jdbnet.co.uk/public/ssh:$VERSION \
|
||||
-t cr.jdbnet.co.uk/public/ssh:latest \
|
||||
--build-arg VERSION=$VERSION \
|
||||
.
|
||||
docker push cr.jdbnet.co.uk/public/ssh:$VERSION
|
||||
docker push cr.jdbnet.co.uk/public/ssh:latest
|
||||
|
||||
- name: Create Gitea Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.VERSION }}
|
||||
name: ${{ steps.get_version.outputs.VERSION }}
|
||||
body: ${{ steps.changelog.outputs.changelog }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -8,6 +8,8 @@ RUN npm run build
|
||||
FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.vendor="JDB-NET"
|
||||
WORKDIR /app
|
||||
ARG VERSION=unknown
|
||||
ENV VERSION=${VERSION}
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . /app
|
||||
|
||||
@@ -9,6 +9,8 @@ A modern, browser-based SSH workspace for managing remote access in one place.
|
||||
|
||||
JDB-NET SSH gives you a clean web interface for opening secure shell sessions, organising hosts, and transferring files over SFTP without juggling multiple desktop tools. It is built for day-to-day server access with a fast tabbed terminal experience, reusable connection identities, and straightforward navigation for both occasional and frequent use.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Secure sign-in before accessing hosts and sessions
|
||||
@@ -28,7 +30,6 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
GEVENT_MONKEY_PATCH: "1"
|
||||
MYSQL_HOST: "<YOUR_MYSQL_HOST>"
|
||||
MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>"
|
||||
MYSQL_USER: "<YOUR_MYSQL_USER>"
|
||||
|
||||
+882
-72
File diff suppressed because it is too large
Load Diff
+112
-18
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async me(): Promise<{ logged_in: boolean }> {
|
||||
async me(): Promise<{ logged_in: boolean; app_version?: string }> {
|
||||
const res = await fetch("/api/me", { credentials: "include" });
|
||||
return handle(res);
|
||||
},
|
||||
@@ -65,6 +65,12 @@ export const api = {
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async listTags(): Promise<string[]> {
|
||||
const res = await fetch("/api/tags", { credentials: "include" });
|
||||
const d = await handle<{ items: string[] }>(res);
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async listFoldersFlat(): Promise<FolderRow[]> {
|
||||
const res = await fetch("/api/folders", { credentials: "include" });
|
||||
const d = await handle<{ items: FolderRow[] }>(res);
|
||||
@@ -92,20 +98,29 @@ export const api = {
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async updateFolder(
|
||||
id: number,
|
||||
body: {
|
||||
label?: string;
|
||||
parent_id?: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/folders/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async listIdentities(): Promise<IdentityRow[]> {
|
||||
const res = await fetch("/api/identities", { credentials: "include" });
|
||||
const d = await handle<{ items: IdentityRow[] }>(res);
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async createHost(body: {
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
identity_id: number;
|
||||
folder_id?: number | null;
|
||||
jump_host_id?: number | null;
|
||||
}): Promise<{ id: number }> {
|
||||
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||
const res = await fetch("/api/hosts", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
@@ -117,14 +132,7 @@ export const api = {
|
||||
|
||||
async patchHost(
|
||||
id: number,
|
||||
body: Partial<{
|
||||
label: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
identity_id: number;
|
||||
folder_id: number | null;
|
||||
jump_host_id: number | null;
|
||||
}>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/hosts/${id}`, {
|
||||
method: "PATCH",
|
||||
@@ -153,6 +161,25 @@ export const api = {
|
||||
return handle(res);
|
||||
},
|
||||
|
||||
async updateIdentity(
|
||||
id: number,
|
||||
body: Partial<{
|
||||
label: string;
|
||||
ssh_username: string;
|
||||
password: string;
|
||||
private_key: string;
|
||||
key_passphrase: string;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/identities/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async deleteIdentity(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/identities/${id}`, {
|
||||
method: "DELETE",
|
||||
@@ -161,8 +188,11 @@ export const api = {
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
|
||||
async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
|
||||
const q = new URLSearchParams({ limit: String(limit) });
|
||||
if (daysBack !== undefined) {
|
||||
q.set("days_back", String(daysBack));
|
||||
}
|
||||
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
@@ -170,6 +200,40 @@ export const api = {
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async listApiKeyScopes(): Promise<ApiKeyScopeDef[]> {
|
||||
const res = await fetch("/api/api-keys/scopes", { credentials: "include" });
|
||||
const d = await handle<{ items: ApiKeyScopeDef[] }>(res);
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async listApiKeys(): Promise<ApiKeyRow[]> {
|
||||
const res = await fetch("/api/api-keys", { credentials: "include" });
|
||||
const d = await handle<{ items: ApiKeyRow[] }>(res);
|
||||
return d.items;
|
||||
},
|
||||
|
||||
async createApiKey(body: {
|
||||
label: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<CreateApiKeyResponse> {
|
||||
const res = await fetch("/api/api-keys", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handle(res);
|
||||
},
|
||||
|
||||
async deleteApiKey(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/api-keys/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
await handle(res);
|
||||
},
|
||||
|
||||
async sftpList(
|
||||
connId: string,
|
||||
path: string,
|
||||
@@ -253,6 +317,8 @@ export interface HostRow {
|
||||
identity_label: string;
|
||||
identity_auth_type: string;
|
||||
folder_label?: string | null;
|
||||
last_connected_at?: string | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface IdentityRow {
|
||||
@@ -279,3 +345,31 @@ export interface ConnectionAuditRow {
|
||||
ended_at: string | null;
|
||||
duration_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface ApiKeyScopeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
label: string;
|
||||
key_prefix: string;
|
||||
scopes: string[];
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
expired: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
id: number;
|
||||
label: string;
|
||||
key_prefix: string;
|
||||
scopes: string[];
|
||||
expires_at: string | null;
|
||||
key: string;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ let ws: WebSocket | null = null;
|
||||
let term: Terminal | null = null;
|
||||
let fit: FitAddon | null = null;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let visibilityHandler: (() => void) | null = null;
|
||||
|
||||
function wsUrl(hostId: number): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -36,6 +37,12 @@ function sendResize() {
|
||||
ws.send(JSON.stringify({ type: "resize", ...dims }));
|
||||
}
|
||||
|
||||
function sendPing() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}
|
||||
|
||||
function fitAndResize() {
|
||||
if (!fit || !term || !props.visible) return;
|
||||
try {
|
||||
@@ -46,6 +53,25 @@ function fitAndResize() {
|
||||
}
|
||||
}
|
||||
|
||||
function isControlMessage(raw: string): boolean {
|
||||
try {
|
||||
const o = JSON.parse(raw) as { type?: string; conn_id?: string };
|
||||
if (o.type === "ready" && o.conn_id) {
|
||||
connId.value = o.conn_id;
|
||||
status.value = "";
|
||||
fitAndResize();
|
||||
term?.focus();
|
||||
return true;
|
||||
}
|
||||
if (o.type === "keepalive" || o.type === "pong") {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON control traffic */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (!termEl.value) return;
|
||||
@@ -91,18 +117,7 @@ onMounted(async () => {
|
||||
ws.onmessage = (ev) => {
|
||||
if (!term) return;
|
||||
if (typeof ev.data === "string") {
|
||||
try {
|
||||
const o = JSON.parse(ev.data) as { type?: string; conn_id?: string };
|
||||
if (o.type === "ready" && o.conn_id) {
|
||||
connId.value = o.conn_id;
|
||||
status.value = "";
|
||||
fitAndResize();
|
||||
term.focus();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (isControlMessage(ev.data)) return;
|
||||
term.write(ev.data);
|
||||
return;
|
||||
}
|
||||
@@ -121,9 +136,21 @@ onMounted(async () => {
|
||||
status.value = "Session ended";
|
||||
}
|
||||
};
|
||||
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
sendPing();
|
||||
fitAndResize();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibilityHandler);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (visibilityHandler) {
|
||||
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||
visibilityHandler = null;
|
||||
}
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
ws?.close();
|
||||
@@ -140,6 +167,7 @@ watch(
|
||||
await nextTick();
|
||||
fitAndResize();
|
||||
term?.focus();
|
||||
sendPing();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
suggestions: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
}>();
|
||||
|
||||
const focused = ref(false);
|
||||
|
||||
function normalizeTag(raw: string): string {
|
||||
return raw.trim().toLowerCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function selectedTags(): Set<string> {
|
||||
return new Set(parseTagsInput(props.modelValue));
|
||||
}
|
||||
|
||||
function parseTagsInput(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const part of raw.split(",")) {
|
||||
const name = normalizeTag(part);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
tags.push(name);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function currentPartial(): string {
|
||||
const val = props.modelValue;
|
||||
const idx = val.lastIndexOf(",");
|
||||
return normalizeTag(idx >= 0 ? val.slice(idx + 1) : val);
|
||||
}
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
const partial = currentPartial();
|
||||
const selected = selectedTags();
|
||||
return props.suggestions
|
||||
.filter((tag) => {
|
||||
if (selected.has(tag)) return false;
|
||||
if (!partial) return true;
|
||||
return tag.includes(partial);
|
||||
})
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
const showSuggestions = computed(
|
||||
() => focused.value && filteredSuggestions.value.length > 0,
|
||||
);
|
||||
|
||||
function applySuggestion(tag: string) {
|
||||
const val = props.modelValue;
|
||||
const idx = val.lastIndexOf(",");
|
||||
const prefix = idx >= 0 ? `${val.slice(0, idx + 1)} ` : "";
|
||||
emit("update:modelValue", `${prefix}${tag}, `);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
window.setTimeout(() => {
|
||||
focused.value = false;
|
||||
}, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="modelValue"
|
||||
placeholder="buildagents, prod"
|
||||
autocomplete="off"
|
||||
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
@focus="focused = true"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<ul
|
||||
v-if="showSuggestions"
|
||||
class="absolute z-20 mt-1 max-h-40 w-full overflow-auto rounded-lg border border-slate-700 bg-surface-raised py-1 shadow-lg"
|
||||
>
|
||||
<li v-for="tag in filteredSuggestions" :key="tag">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
|
||||
@mousedown.prevent="applySuggestion(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "ssh",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user