Compare commits
8 Commits
d542264567
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| cae073728a | |||
| 0f35d5bd6f | |||
| 1d6cce88a8 | |||
| 18e256baee | |||
| 7682a94981 | |||
| bb724377fe | |||
| 22a3dc7cbe | |||
| 6069f5395a |
@@ -1,46 +0,0 @@
|
|||||||
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:latest .
|
|
||||||
docker push cr.jdbnet.co.uk/public/ssh:latest
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -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
|
FROM python:3.14-slim
|
||||||
LABEL org.opencontainers.image.vendor="JDB-NET"
|
LABEL org.opencontainers.image.vendor="JDB-NET"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ARG VERSION=unknown
|
||||||
|
ENV VERSION=${VERSION}
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . /app
|
COPY . /app
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|||||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
||||||
days=int(os.getenv("SESSION_DAYS", "14"))
|
days=int(os.getenv("SESSION_DAYS", "14"))
|
||||||
)
|
)
|
||||||
|
app.config["VERSION"] = os.getenv("VERSION", "unknown")
|
||||||
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
|
if os.getenv("SESSION_COOKIE_SECURE", "").lower() in ("1", "true", "yes"):
|
||||||
app.config["SESSION_COOKIE_SECURE"] = True
|
app.config["SESSION_COOKIE_SECURE"] = True
|
||||||
|
|
||||||
@@ -111,7 +112,7 @@ def init_db():
|
|||||||
label VARCHAR(255) NOT NULL,
|
label VARCHAR(255) NOT NULL,
|
||||||
hostname VARCHAR(512) NOT NULL,
|
hostname VARCHAR(512) NOT NULL,
|
||||||
port INT NOT NULL DEFAULT 22,
|
port INT NOT NULL DEFAULT 22,
|
||||||
identity_id INT,
|
identity_id INT NULL,
|
||||||
inline_identity_auth_type ENUM('password','publickey') NULL,
|
inline_identity_auth_type ENUM('password','publickey') NULL,
|
||||||
inline_identity_encrypted_blob TEXT NULL,
|
inline_identity_encrypted_blob TEXT NULL,
|
||||||
inline_identity_encrypted_key_passphrase TEXT NULL,
|
inline_identity_encrypted_key_passphrase TEXT NULL,
|
||||||
@@ -170,6 +171,21 @@ def _ensure_jump_host_schema(cur) -> None:
|
|||||||
|
|
||||||
def _ensure_inline_identity_schema(cur) -> None:
|
def _ensure_inline_identity_schema(cur) -> None:
|
||||||
"""Migrate existing databases to support inline (one-time) credentials."""
|
"""Migrate existing databases to support inline (one-time) credentials."""
|
||||||
|
# Check and modify identity_id to allow NULL
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ssh_hosts'
|
||||||
|
AND COLUMN_NAME = 'identity_id'
|
||||||
|
AND IS_NULLABLE = 'NO'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if cur.fetchone() is not None:
|
||||||
|
cur.execute("ALTER TABLE ssh_hosts MODIFY COLUMN identity_id INT NULL")
|
||||||
|
|
||||||
# Check and add inline_identity_auth_type column
|
# Check and add inline_identity_auth_type column
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
@@ -662,9 +678,10 @@ def api_logout():
|
|||||||
|
|
||||||
@app.route("/api/me", methods=["GET"])
|
@app.route("/api/me", methods=["GET"])
|
||||||
def api_me():
|
def api_me():
|
||||||
|
version = app.config.get("VERSION", "unknown")
|
||||||
if session.get("logged_in"):
|
if session.get("logged_in"):
|
||||||
return jsonify({"logged_in": True})
|
return jsonify({"logged_in": True, "app_version": version})
|
||||||
return jsonify({"logged_in": False})
|
return jsonify({"logged_in": False, "app_version": version})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/identities", methods=["GET"])
|
@app.route("/api/identities", methods=["GET"])
|
||||||
|
|||||||
+97
-12
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { Folder } from "lucide-vue-next";
|
import { Folder, Pencil, Trash2 } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type HostRow,
|
type HostRow,
|
||||||
@@ -19,6 +19,7 @@ interface TabItem {
|
|||||||
|
|
||||||
const loggedIn = ref(false);
|
const loggedIn = ref(false);
|
||||||
const checking = ref(true);
|
const checking = ref(true);
|
||||||
|
const appVersion = ref("unknown");
|
||||||
const identities = ref<IdentityRow[]>([]);
|
const identities = ref<IdentityRow[]>([]);
|
||||||
const allHosts = ref<HostRow[]>([]);
|
const allHosts = ref<HostRow[]>([]);
|
||||||
const allFolders = ref<FolderRow[]>([]);
|
const allFolders = ref<FolderRow[]>([]);
|
||||||
@@ -50,6 +51,12 @@ const auditShowAll = ref(false);
|
|||||||
const deleteIdentityErr = ref("");
|
const deleteIdentityErr = ref("");
|
||||||
const deleteIdentityErrId = ref<number | null>(null);
|
const deleteIdentityErrId = ref<number | null>(null);
|
||||||
const newFolderLabel = ref("");
|
const newFolderLabel = ref("");
|
||||||
|
const showEditFolder = ref(false);
|
||||||
|
const editFolderForm = ref({
|
||||||
|
id: 0,
|
||||||
|
label: "",
|
||||||
|
parent_id: null as number | null,
|
||||||
|
});
|
||||||
const identityForm = ref({
|
const identityForm = ref({
|
||||||
label: "",
|
label: "",
|
||||||
auth_type: "password" as "password" | "publickey",
|
auth_type: "password" as "password" | "publickey",
|
||||||
@@ -170,6 +177,9 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
const m = await api.me();
|
const m = await api.me();
|
||||||
loggedIn.value = m.logged_in;
|
loggedIn.value = m.logged_in;
|
||||||
|
if (m.app_version) {
|
||||||
|
appVersion.value = m.app_version;
|
||||||
|
}
|
||||||
if (loggedIn.value) await refreshData();
|
if (loggedIn.value) await refreshData();
|
||||||
} catch {
|
} catch {
|
||||||
loggedIn.value = false;
|
loggedIn.value = false;
|
||||||
@@ -376,6 +386,25 @@ async function submitFolder() {
|
|||||||
await refreshBrowse();
|
await refreshBrowse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditFolder(f: FolderRow) {
|
||||||
|
editFolderForm.value = {
|
||||||
|
id: f.id,
|
||||||
|
label: f.label,
|
||||||
|
parent_id: f.parent_id,
|
||||||
|
};
|
||||||
|
showEditFolder.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditFolder() {
|
||||||
|
const f = editFolderForm.value;
|
||||||
|
await api.updateFolder(f.id, {
|
||||||
|
label: f.label.trim(),
|
||||||
|
parent_id: f.parent_id,
|
||||||
|
});
|
||||||
|
showEditFolder.value = false;
|
||||||
|
await refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
function openEditHost(h: HostRow) {
|
function openEditHost(h: HostRow) {
|
||||||
const hasInlineIdentity = h.identity_id === null;
|
const hasInlineIdentity = h.identity_id === null;
|
||||||
editHostForm.value = {
|
editHostForm.value = {
|
||||||
@@ -502,7 +531,15 @@ async function deleteIdentityRow(id: number) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
href="https://git.jdbnet.co.uk/jamie/ssh"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-2 truncate"
|
||||||
|
>
|
||||||
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
||||||
|
<span class="truncate text-xs text-slate-400 hover:text-slate-300">{{ appVersion }}</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -620,13 +657,24 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<span>{{ f.label }}</span>
|
<span>{{ f.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="flex gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-[10px] text-red-400/80 hover:underline"
|
class="text-slate-400/70 hover:text-slate-300"
|
||||||
|
title="Rename folder"
|
||||||
|
@click="openEditFolder(f)"
|
||||||
|
>
|
||||||
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete folder"
|
||||||
@click="deleteFolderRow(f.id)"
|
@click="deleteFolderRow(f.id)"
|
||||||
>
|
>
|
||||||
Del
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="browseHosts.length" class="mb-2 flex items-center justify-between">
|
<div v-if="browseHosts.length" class="mb-2 flex items-center justify-between">
|
||||||
@@ -667,20 +715,22 @@ async function deleteIdentityRow(id: number) {
|
|||||||
>
|
>
|
||||||
{{ folderOptionLabel(h.folder_id) }}
|
{{ folderOptionLabel(h.folder_id) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1 flex gap-2">
|
<div class="mt-1 flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-[10px] text-slate-400 hover:text-white hover:underline"
|
class="text-slate-400/70 hover:text-slate-300"
|
||||||
|
title="Edit host"
|
||||||
@click="openEditHost(h)"
|
@click="openEditHost(h)"
|
||||||
>
|
>
|
||||||
Edit
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-[10px] text-red-400/80 hover:underline"
|
class="text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete host"
|
||||||
@click="deleteHostRow(h.id)"
|
@click="deleteHostRow(h.id)"
|
||||||
>
|
>
|
||||||
Remove
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -720,17 +770,19 @@ async function deleteIdentityRow(id: number) {
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-slate-400/70 hover:text-slate-300 hover:underline"
|
class="shrink-0 text-slate-400/70 hover:text-slate-300"
|
||||||
|
title="Edit identity"
|
||||||
@click="openEditIdentity(i)"
|
@click="openEditIdentity(i)"
|
||||||
>
|
>
|
||||||
Edit
|
<Pencil class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 text-red-400/70 hover:underline"
|
class="shrink-0 text-red-400/70 hover:text-red-400"
|
||||||
|
title="Delete identity"
|
||||||
@click="deleteIdentityRow(i.id)"
|
@click="deleteIdentityRow(i.id)"
|
||||||
>
|
>
|
||||||
×
|
<Trash2 class="h-3 w-3" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1185,6 +1237,39 @@ async function deleteIdentityRow(id: number) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showEditFolder"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitEditFolder"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Rename folder</h2>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="editFolderForm.label"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
|
||||||
|
@click="showEditFolder = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showEditHost"
|
v-if="showEditHost"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
|||||||
+17
-1
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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" });
|
const res = await fetch("/api/me", { credentials: "include" });
|
||||||
return handle(res);
|
return handle(res);
|
||||||
},
|
},
|
||||||
@@ -92,6 +92,22 @@ export const api = {
|
|||||||
await handle(res);
|
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[]> {
|
async listIdentities(): Promise<IdentityRow[]> {
|
||||||
const res = await fetch("/api/identities", { credentials: "include" });
|
const res = await fetch("/api/identities", { credentials: "include" });
|
||||||
const d = await handle<{ items: IdentityRow[] }>(res);
|
const d = await handle<{ items: IdentityRow[] }>(res);
|
||||||
|
|||||||
Submodule
+1
Submodule jdbnet.co.uk added at 4c2697f274
Reference in New Issue
Block a user