Compare commits
32 Commits
feat/406
...
update-ver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b392b747b6 | ||
|
|
580986abfa | ||
|
|
e1d270d52c | ||
|
|
20dbcae42a | ||
|
|
8e8c724392 | ||
|
|
201b33ec84 | ||
|
|
6d2df9929c | ||
|
|
f33504baf5 | ||
|
|
4bc5f4d6ad | ||
|
|
a52a897346 | ||
|
|
1d585d4d3f | ||
|
|
d4b8ceb581 | ||
|
|
7079c236ab | ||
|
|
0678aba911 | ||
|
|
ffdd742aa0 | ||
|
|
f4de214a83 | ||
|
|
3b0da19cd1 | ||
|
|
08bc4ab37b | ||
|
|
d2e7477898 | ||
|
|
b5c6beafff | ||
|
|
a34566651a | ||
|
|
4628e67e5c | ||
|
|
578fa28461 | ||
|
|
9e6154b0de | ||
|
|
d29f71a92f | ||
|
|
aea14cda7e | ||
|
|
4893ccda6e | ||
|
|
c06b8e6731 | ||
|
|
14e01513e3 | ||
|
|
2e4634ca25 | ||
|
|
a82bc02b15 | ||
|
|
2ea44e6b24 |
18
.github/workflows/publish_release.yml
vendored
18
.github/workflows/publish_release.yml
vendored
@@ -31,20 +31,24 @@ jobs:
|
|||||||
echo "Found draft version: ${{ steps.draft.outputs.tag_name }}"
|
echo "Found draft version: ${{ steps.draft.outputs.tag_name }}"
|
||||||
|
|
||||||
|
|
||||||
- name: Create branch and commit VERSION
|
- name: Create branch and commit VERSION and package.json
|
||||||
run: |
|
run: |
|
||||||
branch="update-version-${{ steps.draft.outputs.tag_name }}"
|
branch="update-version-${{ steps.draft.outputs.tag_name }}"
|
||||||
# Delete remote branch if exists
|
# Delete remote branch if exists
|
||||||
git push origin --delete "$branch" || echo "No remote branch to delete"
|
git push origin --delete "$branch" || echo "No remote branch to delete"
|
||||||
git fetch origin main
|
git fetch origin main
|
||||||
git checkout -b "$branch" origin/main
|
git checkout -b "$branch" origin/main
|
||||||
# Write VERSION file and timestamp to ensure a diff
|
# Version without 'v' prefix (e.g. v1.2.3 -> 1.2.3)
|
||||||
version="${{ steps.draft.outputs.tag_name }}"
|
version="${{ steps.draft.outputs.tag_name }}"
|
||||||
echo "$version" | sed 's/^v//' > VERSION
|
version_plain=$(echo "$version" | sed 's/^v//')
|
||||||
git add VERSION
|
# Write VERSION file
|
||||||
|
echo "$version_plain" > VERSION
|
||||||
|
# Update package.json version
|
||||||
|
jq --arg v "$version_plain" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||||
|
git add VERSION package.json
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git commit -m "chore: add VERSION $version" --allow-empty
|
git commit -m "chore: bump version to $version_plain (VERSION + package.json)" --allow-empty
|
||||||
|
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
run: |
|
run: |
|
||||||
@@ -57,8 +61,8 @@ jobs:
|
|||||||
pr_url=$(gh pr create \
|
pr_url=$(gh pr create \
|
||||||
--base main \
|
--base main \
|
||||||
--head update-version-${{ steps.draft.outputs.tag_name }} \
|
--head update-version-${{ steps.draft.outputs.tag_name }} \
|
||||||
--title "chore: add VERSION ${{ steps.draft.outputs.tag_name }}" \
|
--title "chore: bump version to ${{ steps.draft.outputs.tag_name }} (VERSION + package.json)" \
|
||||||
--body "Adds VERSION file for release ${{ steps.draft.outputs.tag_name }}" \
|
--body "Updates VERSION file and package.json version for release ${{ steps.draft.outputs.tag_name }}" \
|
||||||
--label automated)
|
--label automated)
|
||||||
|
|
||||||
pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
|
pr_number=$(echo "$pr_url" | awk -F/ '{print $NF}')
|
||||||
|
|||||||
614
package-lock.json
generated
614
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pve-scripts-local",
|
"name": "pve-scripts-local",
|
||||||
"version": "0.1.0",
|
"version": "0.5.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,13 +25,13 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-better-sqlite3": "^7.2.0",
|
"@prisma/adapter-better-sqlite3": "^7.3.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@t3-oss/env-nextjs": "^0.13.10",
|
"@t3-oss/env-nextjs": "^0.13.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.18",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"@trpc/client": "^11.8.1",
|
"@trpc/client": "^11.8.1",
|
||||||
"@trpc/react-query": "^11.8.1",
|
"@trpc/react-query": "^11.8.1",
|
||||||
"@trpc/server": "^11.8.1",
|
"@trpc/server": "^11.8.1",
|
||||||
@@ -42,14 +42,14 @@
|
|||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cron-validator": "^1.4.0",
|
"cron-validator": "^1.4.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.3",
|
"next": ">=16.1.5",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
@@ -66,9 +66,10 @@
|
|||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"next": ">=16.1.5",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
@@ -87,11 +88,11 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"prisma": "^7.2.0",
|
"prisma": "^7.3.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.53.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
@@ -102,6 +103,7 @@
|
|||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"prismjs": "^1.30.0"
|
"prismjs": "^1.30.0",
|
||||||
|
"hono": ">=4.11.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
66
server.js
66
server.js
@@ -3,6 +3,7 @@ import { parse } from 'url';
|
|||||||
import next from 'next';
|
import next from 'next';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
@@ -56,6 +57,8 @@ const handle = app.getRequestHandler();
|
|||||||
* @property {string} user
|
* @property {string} user
|
||||||
* @property {string} password
|
* @property {string} password
|
||||||
* @property {number} [id]
|
* @property {number} [id]
|
||||||
|
* @property {string} [auth_type]
|
||||||
|
* @property {string} [ssh_key_path]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,6 +298,20 @@ class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve full server from DB when client sends server with id but no ssh_key_path (e.g. for Shell/Update over SSH).
|
||||||
|
* @param {ServerInfo|null} server - Server from WebSocket message
|
||||||
|
* @returns {Promise<ServerInfo|null>} Same server or full server from DB
|
||||||
|
*/
|
||||||
|
async resolveServerForSSH(server) {
|
||||||
|
if (!server?.id) return server;
|
||||||
|
if (server.auth_type === 'key' && (!server.ssh_key_path || !existsSync(server.ssh_key_path))) {
|
||||||
|
const full = await this.db.getServerById(server.id);
|
||||||
|
return /** @type {ServerInfo|null} */ (full ?? server);
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ExtendedWebSocket} ws
|
* @param {ExtendedWebSocket} ws
|
||||||
* @param {WebSocketMessage} message
|
* @param {WebSocketMessage} message
|
||||||
@@ -305,16 +322,21 @@ class ScriptExecutionHandler {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
if (scriptPath && executionId) {
|
if (scriptPath && executionId) {
|
||||||
|
let serverToUse = server;
|
||||||
|
if (serverToUse?.id) {
|
||||||
|
serverToUse = await this.resolveServerForSSH(serverToUse) ?? serverToUse;
|
||||||
|
}
|
||||||
|
const resolved = serverToUse ?? server;
|
||||||
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
|
if (isClone && containerId && storage && server && cloneCount && hostnames && containerType) {
|
||||||
await this.startSSHCloneExecution(ws, containerId, executionId, storage, server, containerType, cloneCount, hostnames);
|
await this.startSSHCloneExecution(ws, containerId, executionId, storage, /** @type {ServerInfo} */ (resolved), containerType, cloneCount, hostnames);
|
||||||
} else if (isBackup && containerId && storage) {
|
} else if (isBackup && containerId && storage) {
|
||||||
await this.startBackupExecution(ws, containerId, executionId, storage, mode, server);
|
await this.startBackupExecution(ws, containerId, executionId, storage, mode, resolved);
|
||||||
} else if (isUpdate && containerId) {
|
} else if (isUpdate && containerId) {
|
||||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server, backupStorage);
|
await this.startUpdateExecution(ws, containerId, executionId, mode, resolved, backupStorage);
|
||||||
} else if (isShell && containerId) {
|
} else if (isShell && containerId) {
|
||||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
await this.startShellExecution(ws, containerId, executionId, mode, resolved, containerType);
|
||||||
} else {
|
} else {
|
||||||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server, envVars);
|
await this.startScriptExecution(ws, scriptPath, executionId, mode, resolved, envVars);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
@@ -1153,10 +1175,11 @@ class ScriptExecutionHandler {
|
|||||||
const hostname = hostnames[i];
|
const hostname = hostnames[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read config file to get hostname/name
|
// Read config file to get hostname/name (node-specific path)
|
||||||
|
const nodeName = server.name;
|
||||||
const configPath = containerType === 'lxc'
|
const configPath = containerType === 'lxc'
|
||||||
? `/etc/pve/lxc/${nextId}.conf`
|
? `/etc/pve/nodes/${nodeName}/lxc/${nextId}.conf`
|
||||||
: `/etc/pve/qemu-server/${nextId}.conf`;
|
: `/etc/pve/nodes/${nodeName}/qemu-server/${nextId}.conf`;
|
||||||
|
|
||||||
let configContent = '';
|
let configContent = '';
|
||||||
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
|
await new Promise(/** @type {(resolve: (value?: void) => void) => void} */ ((resolve) => {
|
||||||
@@ -1474,21 +1497,21 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {string} mode
|
* @param {string} mode
|
||||||
* @param {ServerInfo|null} server
|
* @param {ServerInfo|null} server
|
||||||
|
* @param {'lxc'|'vm'} [containerType='lxc']
|
||||||
*/
|
*/
|
||||||
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null, containerType = 'lxc') {
|
||||||
try {
|
try {
|
||||||
|
const typeLabel = containerType === 'vm' ? 'VM' : 'container';
|
||||||
// Send start message
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
data: `Starting shell session for container ${containerId}...`,
|
data: `Starting shell session for ${typeLabel} ${containerId}...`,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mode === 'ssh' && server) {
|
if (mode === 'ssh' && server) {
|
||||||
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
await this.startSSHShellExecution(ws, containerId, executionId, server, containerType);
|
||||||
} else {
|
} else {
|
||||||
await this.startLocalShellExecution(ws, containerId, executionId);
|
await this.startLocalShellExecution(ws, containerId, executionId, containerType);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1505,12 +1528,12 @@ class ScriptExecutionHandler {
|
|||||||
* @param {ExtendedWebSocket} ws
|
* @param {ExtendedWebSocket} ws
|
||||||
* @param {string} containerId
|
* @param {string} containerId
|
||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
|
* @param {'lxc'|'vm'} [containerType='lxc']
|
||||||
*/
|
*/
|
||||||
async startLocalShellExecution(ws, containerId, executionId) {
|
async startLocalShellExecution(ws, containerId, executionId, containerType = 'lxc') {
|
||||||
const { spawn } = await import('node-pty');
|
const { spawn } = await import('node-pty');
|
||||||
|
const shellCommand = containerType === 'vm' ? `qm terminal ${containerId}` : `pct enter ${containerId}`;
|
||||||
// Create a shell process that will run pct enter
|
const childProcess = spawn('bash', ['-c', shellCommand], {
|
||||||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
|
||||||
name: 'xterm-color',
|
name: 'xterm-color',
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 24,
|
rows: 24,
|
||||||
@@ -1553,14 +1576,15 @@ class ScriptExecutionHandler {
|
|||||||
* @param {string} containerId
|
* @param {string} containerId
|
||||||
* @param {string} executionId
|
* @param {string} executionId
|
||||||
* @param {ServerInfo} server
|
* @param {ServerInfo} server
|
||||||
|
* @param {'lxc'|'vm'} [containerType='lxc']
|
||||||
*/
|
*/
|
||||||
async startSSHShellExecution(ws, containerId, executionId, server) {
|
async startSSHShellExecution(ws, containerId, executionId, server, containerType = 'lxc') {
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
|
const shellCommand = containerType === 'vm' ? `qm terminal ${containerId}` : `pct enter ${containerId}`;
|
||||||
try {
|
try {
|
||||||
const execution = await sshService.executeCommand(
|
const execution = await sshService.executeCommand(
|
||||||
server,
|
server,
|
||||||
`pct enter ${containerId}`,
|
shellCommand,
|
||||||
/** @param {string} data */
|
/** @param {string} data */
|
||||||
(data) => {
|
(data) => {
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ export function ConfigurationModal({
|
|||||||
return !isNaN(num) && num > 0;
|
return !isNaN(num) && num > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateHostname = (hostname: string): boolean => {
|
||||||
|
if (!hostname || hostname.length > 253) return false;
|
||||||
|
const label = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||||
|
const labels = hostname.split('.');
|
||||||
|
return labels.length >= 1 && labels.every(l => l.length >= 1 && l.length <= 63 && label.test(l));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAptCacherAddress = (value: string): boolean => {
|
||||||
|
return validateIPv4(value) || validateHostname(value);
|
||||||
|
};
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -216,8 +227,8 @@ export function ConfigurationModal({
|
|||||||
if (advancedVars.var_ns && !validateIPv4(advancedVars.var_ns as string)) {
|
if (advancedVars.var_ns && !validateIPv4(advancedVars.var_ns as string)) {
|
||||||
newErrors.var_ns = 'Invalid IPv4 address';
|
newErrors.var_ns = 'Invalid IPv4 address';
|
||||||
}
|
}
|
||||||
if (advancedVars.var_apt_cacher_ip && !validateIPv4(advancedVars.var_apt_cacher_ip as string)) {
|
if (advancedVars.var_apt_cacher_ip && !validateAptCacherAddress(advancedVars.var_apt_cacher_ip as string)) {
|
||||||
newErrors.var_apt_cacher_ip = 'Invalid IPv4 address';
|
newErrors.var_apt_cacher_ip = 'Invalid IPv4 address or hostname';
|
||||||
}
|
}
|
||||||
// Validate IPv4 CIDR if network mode is static
|
// Validate IPv4 CIDR if network mode is static
|
||||||
const netValue = advancedVars.var_net;
|
const netValue = advancedVars.var_net;
|
||||||
@@ -904,13 +915,13 @@ export function ConfigurationModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
APT Cacher IP
|
APT Cacher host or IP
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={typeof advancedVars.var_apt_cacher_ip === 'boolean' ? '' : String(advancedVars.var_apt_cacher_ip ?? '')}
|
value={typeof advancedVars.var_apt_cacher_ip === 'boolean' ? '' : String(advancedVars.var_apt_cacher_ip ?? '')}
|
||||||
onChange={(e) => updateAdvancedVar('var_apt_cacher_ip', e.target.value)}
|
onChange={(e) => updateAdvancedVar('var_apt_cacher_ip', e.target.value)}
|
||||||
placeholder="192.168.1.10"
|
placeholder="192.168.1.10 or apt-cacher.internal"
|
||||||
className={errors.var_apt_cacher_ip ? 'border-destructive' : ''}
|
className={errors.var_apt_cacher_ip ? 'border-destructive' : ''}
|
||||||
/>
|
/>
|
||||||
{errors.var_apt_cacher_ip && (
|
{errors.var_apt_cacher_ip && (
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function InstalledScriptsTab() {
|
|||||||
id: number;
|
id: number;
|
||||||
containerId: string;
|
containerId: string;
|
||||||
server?: any;
|
server?: any;
|
||||||
|
containerType?: 'lxc' | 'vm';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showBackupPrompt, setShowBackupPrompt] = useState(false);
|
const [showBackupPrompt, setShowBackupPrompt] = useState(false);
|
||||||
const [showStorageSelection, setShowStorageSelection] = useState(false);
|
const [showStorageSelection, setShowStorageSelection] = useState(false);
|
||||||
@@ -1167,6 +1168,7 @@ export function InstalledScriptsTab() {
|
|||||||
id: script.id,
|
id: script.id,
|
||||||
containerId: script.container_id,
|
containerId: script.container_id,
|
||||||
server: server,
|
server: server,
|
||||||
|
containerType: script.is_vm ? 'vm' : 'lxc',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1452,6 +1454,13 @@ export function InstalledScriptsTab() {
|
|||||||
{/* Shell Terminal */}
|
{/* Shell Terminal */}
|
||||||
{openingShell && (
|
{openingShell && (
|
||||||
<div className="mb-8" data-terminal="shell">
|
<div className="mb-8" data-terminal="shell">
|
||||||
|
{openingShell.containerType === 'vm' && (
|
||||||
|
<p className="text-muted-foreground mb-2 text-sm">
|
||||||
|
VM shell uses the Proxmox serial console. The VM must have a
|
||||||
|
serial port configured (e.g. <code className="bg-muted rounded px-1">qm set {openingShell.containerId} -serial0 socket</code>).
|
||||||
|
Detach with <kbd className="bg-muted rounded px-1">Ctrl+O</kbd>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Terminal
|
<Terminal
|
||||||
scriptPath={`shell-${openingShell.containerId}`}
|
scriptPath={`shell-${openingShell.containerId}`}
|
||||||
onClose={handleCloseShellTerminal}
|
onClose={handleCloseShellTerminal}
|
||||||
@@ -1459,6 +1468,7 @@ export function InstalledScriptsTab() {
|
|||||||
server={openingShell.server}
|
server={openingShell.server}
|
||||||
isShell={true}
|
isShell={true}
|
||||||
containerId={openingShell.containerId}
|
containerId={openingShell.containerId}
|
||||||
|
containerType={openingShell.containerType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1538,7 +1548,7 @@ export function InstalledScriptsTab() {
|
|||||||
>
|
>
|
||||||
{showAutoDetectForm
|
{showAutoDetectForm
|
||||||
? "Cancel Auto-Detect"
|
? "Cancel Auto-Detect"
|
||||||
: '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
: '🔍 Auto-Detect Containers & VMs (tag: community-script)'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1764,12 +1774,11 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto-Detect LXC Containers Form */}
|
{/* Auto-Detect Containers & VMs Form */}
|
||||||
{showAutoDetectForm && (
|
{showAutoDetectForm && (
|
||||||
<div className="bg-card border-border mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
|
<div className="bg-card border-border mb-6 rounded-lg border p-4 shadow-sm sm:p-6">
|
||||||
<h3 className="text-foreground mb-4 text-lg font-semibold sm:mb-6">
|
<h3 className="text-foreground mb-4 text-lg font-semibold sm:mb-6">
|
||||||
Auto-Detect LXC Containers (Must contain a tag with
|
Auto-Detect Containers & VMs (tag: community-script)
|
||||||
"community-script")
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="bg-muted/30 border-muted rounded-lg border p-4">
|
<div className="bg-muted/30 border-muted rounded-lg border p-4">
|
||||||
@@ -1795,12 +1804,12 @@ export function InstalledScriptsTab() {
|
|||||||
<p>This feature will:</p>
|
<p>This feature will:</p>
|
||||||
<ul className="mt-1 list-inside list-disc space-y-1">
|
<ul className="mt-1 list-inside list-disc space-y-1">
|
||||||
<li>Connect to the selected server via SSH</li>
|
<li>Connect to the selected server via SSH</li>
|
||||||
<li>Scan all LXC config files in /etc/pve/lxc/</li>
|
<li>Scan LXC configs in /etc/pve/lxc/ and VM configs in /etc/pve/qemu-server/</li>
|
||||||
<li>
|
<li>
|
||||||
Find containers with "community-script" in
|
Find containers and VMs with "community-script" in
|
||||||
their tags
|
their tags
|
||||||
</li>
|
</li>
|
||||||
<li>Extract the container ID and hostname</li>
|
<li>Extract the container/VM ID and hostname or name</li>
|
||||||
<li>Add them as installed script entries</li>
|
<li>Add them as installed script entries</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -2302,6 +2311,11 @@ export function InstalledScriptsTab() {
|
|||||||
"stopped"
|
"stopped"
|
||||||
}
|
}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
|
||||||
|
title={
|
||||||
|
script.is_vm
|
||||||
|
? "VM serial console (requires serial port; detach with Ctrl+O)"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Shell
|
Shell
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -270,22 +270,21 @@ export function PBSCredentialsModal({
|
|||||||
htmlFor="pbs-fingerprint"
|
htmlFor="pbs-fingerprint"
|
||||||
className="text-foreground mb-1 block text-sm font-medium"
|
className="text-foreground mb-1 block text-sm font-medium"
|
||||||
>
|
>
|
||||||
Fingerprint <span className="text-error">*</span>
|
Fingerprint
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="pbs-fingerprint"
|
id="pbs-fingerprint"
|
||||||
value={pbsFingerprint}
|
value={pbsFingerprint}
|
||||||
onChange={(e) => setPbsFingerprint(e.target.value)}
|
onChange={(e) => setPbsFingerprint(e.target.value)}
|
||||||
required
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
className="bg-card text-foreground placeholder-muted-foreground focus:ring-ring focus:border-ring border-border w-full rounded-md border px-3 py-2 shadow-sm focus:ring-2 focus:outline-none"
|
||||||
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
placeholder="e.g., 7b:e5:87:38:5e:16:05:d1:12:22:7f:73:d2:e2:d0:cf:8c:cb:28:e2:74:0c:78:91:1a:71:74:2e:79:20:5a:02"
|
||||||
/>
|
/>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
Server fingerprint for auto-acceptance. You can find this on
|
Leave empty if PBS uses a trusted CA (e.g. Let's Encrypt).
|
||||||
your PBS dashboard by clicking the "Show Fingerprint"
|
For self-signed certificates, enter the server fingerprint from
|
||||||
button.
|
the PBS dashboard ("Show Fingerprint").
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,11 @@ export function ServerForm({
|
|||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
|
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
SSH key is recommended when possible. Special characters (e.g.{" "}
|
||||||
|
<code className="rounded bg-muted px-0.5">{"{ } $ \" '"}</code>) are
|
||||||
|
supported.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -418,44 +418,46 @@ async function isVM(scriptId: number, containerId: string, serverId: number | nu
|
|||||||
return false; // Default to LXC if SSH fails
|
return false; // Default to LXC if SSH fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check both config file paths
|
// Node-specific paths (multi-node Proxmox: /etc/pve/nodes/NODENAME/...)
|
||||||
const vmConfigPath = `/etc/pve/qemu-server/${containerId}.conf`;
|
const nodeName = (server as Server).name;
|
||||||
const lxcConfigPath = `/etc/pve/lxc/${containerId}.conf`;
|
const vmConfigPathNode = `/etc/pve/nodes/${nodeName}/qemu-server/${containerId}.conf`;
|
||||||
|
const lxcConfigPathNode = `/etc/pve/nodes/${nodeName}/lxc/${containerId}.conf`;
|
||||||
|
// Fallback for single-node or when server.name is not the Proxmox node name
|
||||||
|
const vmConfigPathFallback = `/etc/pve/qemu-server/${containerId}.conf`;
|
||||||
|
const lxcConfigPathFallback = `/etc/pve/lxc/${containerId}.conf`;
|
||||||
|
|
||||||
// Check VM config file
|
const checkPathExists = (path: string): Promise<boolean> =>
|
||||||
let vmConfigExists = false;
|
new Promise<boolean>((resolve) => {
|
||||||
await new Promise<void>((resolve) => {
|
let exists = false;
|
||||||
void sshExecutionService.executeCommand(
|
void sshExecutionService.executeCommand(
|
||||||
server as Server,
|
server as Server,
|
||||||
`test -f "${vmConfigPath}" && echo "exists" || echo "not_exists"`,
|
`test -f "${path}" && echo "exists" || echo "not_exists"`,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
if (data.includes('exists')) {
|
if (data.includes('exists')) exists = true;
|
||||||
vmConfigExists = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
() => resolve(),
|
() => resolve(exists),
|
||||||
() => resolve()
|
() => resolve(exists)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (vmConfigExists) {
|
// Prefer node-specific paths first
|
||||||
return true; // VM config file exists
|
const vmConfigExistsNode = await checkPathExists(vmConfigPathNode);
|
||||||
|
if (vmConfigExistsNode) {
|
||||||
|
return true; // VM config file exists on node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check LXC config file (not needed for return value, but check for completeness)
|
const lxcConfigExistsNode = await checkPathExists(lxcConfigPathNode);
|
||||||
await new Promise<void>((resolve) => {
|
if (lxcConfigExistsNode) {
|
||||||
void sshExecutionService.executeCommand(
|
return false; // LXC config file exists on node
|
||||||
server as Server,
|
}
|
||||||
`test -f "${lxcConfigPath}" && echo "exists" || echo "not_exists"`,
|
|
||||||
(_data: string) => {
|
|
||||||
// Data handler not needed - just checking if file exists
|
|
||||||
},
|
|
||||||
() => resolve(),
|
|
||||||
() => resolve()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return false; // Always LXC since VM config doesn't exist
|
// Fallback: single-node or server.name not matching Proxmox node name
|
||||||
|
const vmConfigExistsFallback = await checkPathExists(vmConfigPathFallback);
|
||||||
|
if (vmConfigExistsFallback) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // LXC (or neither path exists)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error determining container type:', error);
|
console.error('Error determining container type:', error);
|
||||||
return false; // Default to LXC on error
|
return false; // Default to LXC on error
|
||||||
@@ -971,10 +973,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check config file for community-script tag and extract hostname/name
|
// Helper function to check config file for community-script tag and extract hostname/name
|
||||||
|
const nodeName = (server as Server).name;
|
||||||
const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise<any> => {
|
const checkConfigAndExtractInfo = async (id: string, isVM: boolean): Promise<any> => {
|
||||||
const configPath = isVM
|
const configPath = isVM
|
||||||
? `/etc/pve/qemu-server/${id}.conf`
|
? `/etc/pve/nodes/${nodeName}/qemu-server/${id}.conf`
|
||||||
: `/etc/pve/lxc/${id}.conf`;
|
: `/etc/pve/nodes/${nodeName}/lxc/${id}.conf`;
|
||||||
|
|
||||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
|
|
||||||
@@ -1060,7 +1063,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
reject(new Error(`pct list failed: ${error}`));
|
reject(new Error(`pct list failed: ${error}`));
|
||||||
},
|
},
|
||||||
(_exitCode: number) => {
|
(_exitCode: number) => {
|
||||||
resolve();
|
setImmediate(() => resolve());
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1079,7 +1082,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
reject(new Error(`qm list failed: ${error}`));
|
reject(new Error(`qm list failed: ${error}`));
|
||||||
},
|
},
|
||||||
(_exitCode: number) => {
|
(_exitCode: number) => {
|
||||||
resolve();
|
setImmediate(() => resolve());
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1318,10 +1321,10 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Check if ID exists in either pct list (containers) or qm list (VMs)
|
// Check if ID exists in either pct list (containers) or qm list (VMs)
|
||||||
if (!existingIds.has(containerId)) {
|
if (!existingIds.has(containerId)) {
|
||||||
// Also verify config file doesn't exist as a double-check
|
// Also verify config file doesn't exist as a double-check (node-specific paths)
|
||||||
// Check both container and VM config paths
|
const nodeName = (server as Server).name;
|
||||||
const checkContainerCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
const checkContainerCommand = `test -f "/etc/pve/nodes/${nodeName}/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||||
const checkVMCommand = `test -f "/etc/pve/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
const checkVMCommand = `test -f "/etc/pve/nodes/${nodeName}/qemu-server/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||||
|
|
||||||
const configExists = await new Promise<boolean>((resolve) => {
|
const configExists = await new Promise<boolean>((resolve) => {
|
||||||
let combinedOutput = '';
|
let combinedOutput = '';
|
||||||
@@ -2068,32 +2071,72 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the script's interface_port from metadata (prioritize metadata over existing database values)
|
// Resolve app slug from /usr/bin/update (community-scripts) when available; else from hostname/suffix.
|
||||||
|
let slugFromUpdate: string | null = null;
|
||||||
|
try {
|
||||||
|
const updateCommand = `pct exec ${scriptData.container_id} -- cat /usr/bin/update 2>/dev/null`;
|
||||||
|
let updateOutput = '';
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
server as Server,
|
||||||
|
updateCommand,
|
||||||
|
(data: string) => { updateOutput += data; },
|
||||||
|
() => {},
|
||||||
|
() => resolve()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const ctSlugMatch = /ct\/([a-zA-Z0-9_.-]+)\.sh/.exec(updateOutput);
|
||||||
|
if (ctSlugMatch?.[1]) {
|
||||||
|
slugFromUpdate = ctSlugMatch[1].trim().toLowerCase();
|
||||||
|
console.log('🔍 Slug from /usr/bin/update:', slugFromUpdate);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Container may not be from community-scripts; use hostname fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the script's interface_port from metadata. Primary: slug from /usr/bin/update; fallback: hostname/suffix.
|
||||||
let detectedPort = 80; // Default fallback
|
let detectedPort = 80; // Default fallback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import localScriptsService to get script metadata
|
|
||||||
const { localScriptsService } = await import('~/server/services/localScripts');
|
const { localScriptsService } = await import('~/server/services/localScripts');
|
||||||
|
|
||||||
// Get all scripts and find the one matching our script name
|
|
||||||
const allScripts = await localScriptsService.getAllScripts();
|
const allScripts = await localScriptsService.getAllScripts();
|
||||||
|
|
||||||
// Extract script slug from script_name (remove .sh extension)
|
const nameFromHostname = scriptData.script_name.replace(/\.sh$/, '').toLowerCase();
|
||||||
const scriptSlug = scriptData.script_name.replace(/\.sh$/, '');
|
|
||||||
console.log('🔍 Looking for script with slug:', scriptSlug);
|
|
||||||
|
|
||||||
const scriptMetadata = allScripts.find(script => script.slug === scriptSlug);
|
// Primary: slug from /usr/bin/update (community-scripts)
|
||||||
|
let scriptMetadata =
|
||||||
|
slugFromUpdate != null
|
||||||
|
? allScripts.find((s) => s.slug === slugFromUpdate)
|
||||||
|
: undefined;
|
||||||
|
if (scriptMetadata) {
|
||||||
|
console.log('🔍 Using slug from /usr/bin/update for metadata:', scriptMetadata.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: exact hostname then hostname ends with slug (longest wins)
|
||||||
|
if (!scriptMetadata) {
|
||||||
|
scriptMetadata = allScripts.find((script) => script.slug === nameFromHostname);
|
||||||
|
if (!scriptMetadata) {
|
||||||
|
const suffixMatches = allScripts.filter((script) => nameFromHostname.endsWith(script.slug));
|
||||||
|
scriptMetadata =
|
||||||
|
suffixMatches.length > 0
|
||||||
|
? suffixMatches.reduce((a, b) => (a.slug.length >= b.slug.length ? a : b))
|
||||||
|
: undefined;
|
||||||
|
if (scriptMetadata) {
|
||||||
|
console.log('🔍 Matched metadata by slug suffix in hostname:', scriptMetadata.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (scriptMetadata?.interface_port) {
|
if (scriptMetadata?.interface_port) {
|
||||||
detectedPort = scriptMetadata.interface_port;
|
detectedPort = scriptMetadata.interface_port;
|
||||||
console.log('📋 Found interface_port in metadata:', detectedPort);
|
console.log('📋 Found interface_port in metadata:', detectedPort);
|
||||||
} else {
|
} else {
|
||||||
console.log('📋 No interface_port found in metadata, using default port 80');
|
console.log('📋 No interface_port found in metadata, using default port 80');
|
||||||
detectedPort = 80; // Default to port 80 if no metadata port found
|
detectedPort = 80;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('⚠️ Error getting script metadata, using default port 80:', error);
|
console.log('⚠️ Error getting script metadata, using default port 80:', error);
|
||||||
detectedPort = 80; // Default to port 80 if metadata lookup fails
|
detectedPort = 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🎯 Final detected port:', detectedPort);
|
console.log('🎯 Final detected port:', detectedPort);
|
||||||
@@ -2197,8 +2240,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read config file
|
// Read config file (node-specific path)
|
||||||
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
|
const nodeName = (server as Server).name;
|
||||||
|
const configPath = `/etc/pve/nodes/${nodeName}/lxc/${script.container_id}.conf`;
|
||||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
let rawConfig = '';
|
let rawConfig = '';
|
||||||
|
|
||||||
@@ -2328,8 +2372,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write config file using heredoc for safe escaping
|
// Write config file using heredoc for safe escaping (node-specific path)
|
||||||
const configPath = `/etc/pve/lxc/${script.container_id}.conf`;
|
const nodeName = (server as Server).name;
|
||||||
|
const configPath = `/etc/pve/nodes/${nodeName}/lxc/${script.container_id}.conf`;
|
||||||
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
const writeCommand = `cat > "${configPath}" << 'EOFCONFIG'
|
||||||
${rawConfig}
|
${rawConfig}
|
||||||
EOFCONFIG`;
|
EOFCONFIG`;
|
||||||
@@ -2737,9 +2782,10 @@ EOFCONFIG`;
|
|||||||
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
const sshExecutionService = getSSHExecutionService();
|
const sshExecutionService = getSSHExecutionService();
|
||||||
|
|
||||||
|
const nodeName = (server as Server).name;
|
||||||
const configPath = input.containerType === 'lxc'
|
const configPath = input.containerType === 'lxc'
|
||||||
? `/etc/pve/lxc/${input.containerId}.conf`
|
? `/etc/pve/nodes/${nodeName}/lxc/${input.containerId}.conf`
|
||||||
: `/etc/pve/qemu-server/${input.containerId}.conf`;
|
: `/etc/pve/nodes/${nodeName}/qemu-server/${input.containerId}.conf`;
|
||||||
|
|
||||||
let configContent = '';
|
let configContent = '';
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -3131,10 +3177,11 @@ EOFCONFIG`;
|
|||||||
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
const { getSSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
const sshExecutionService = getSSHExecutionService();
|
const sshExecutionService = getSSHExecutionService();
|
||||||
|
|
||||||
// Read config file to get hostname/name
|
// Read config file to get hostname/name (node-specific path)
|
||||||
|
const nodeName = (server as Server).name;
|
||||||
const configPath = input.containerType === 'lxc'
|
const configPath = input.containerType === 'lxc'
|
||||||
? `/etc/pve/lxc/${input.containerId}.conf`
|
? `/etc/pve/nodes/${nodeName}/lxc/${input.containerId}.conf`
|
||||||
: `/etc/pve/qemu-server/${input.containerId}.conf`;
|
: `/etc/pve/nodes/${nodeName}/qemu-server/${input.containerId}.conf`;
|
||||||
|
|
||||||
let configContent = '';
|
let configContent = '';
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|||||||
@@ -327,13 +327,16 @@ class BackupService {
|
|||||||
// PBS supports PBS_PASSWORD and PBS_REPOSITORY environment variables for non-interactive login
|
// PBS supports PBS_PASSWORD and PBS_REPOSITORY environment variables for non-interactive login
|
||||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||||
|
|
||||||
// Escape password for shell safety (single quotes)
|
// Escape password and fingerprint for shell safety (single quotes)
|
||||||
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
||||||
|
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
|
||||||
// Use PBS_PASSWORD environment variable for non-interactive authentication
|
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
|
||||||
// Auto-accept fingerprint by piping "y" to stdin
|
const envParts = [`PBS_PASSWORD='${escapedPassword}'`, `PBS_REPOSITORY='${repository}'`];
|
||||||
// PBS will use PBS_PASSWORD env var if available, avoiding interactive prompt
|
if (escapedFingerprint) {
|
||||||
const fullCommand = `echo "y" | PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`;
|
envParts.push(`PBS_FINGERPRINT='${escapedFingerprint}'`);
|
||||||
|
}
|
||||||
|
const envStr = envParts.join(' ');
|
||||||
|
const fullCommand = `${envStr} timeout 10 proxmox-backup-client login --repository ${repository} 2>&1`;
|
||||||
|
|
||||||
console.log(`[BackupService] Logging into PBS: ${repository}`);
|
console.log(`[BackupService] Logging into PBS: ${repository}`);
|
||||||
|
|
||||||
@@ -419,9 +422,12 @@ class BackupService {
|
|||||||
|
|
||||||
// Build full repository string: root@pam@<IP>:<DATASTORE>
|
// Build full repository string: root@pam@<IP>:<DATASTORE>
|
||||||
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
const repository = `root@pam@${pbsIp}:${pbsDatastore}`;
|
||||||
|
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
|
||||||
|
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
|
||||||
|
const snapshotEnvParts = escapedFingerprint ? [`PBS_FINGERPRINT='${escapedFingerprint}'`] : [];
|
||||||
|
const snapshotEnvStr = snapshotEnvParts.length ? snapshotEnvParts.join(' ') + ' ' : '';
|
||||||
// Use correct command: snapshot list ct/<CT_ID> --repository <full_repo_string>
|
// Use correct command: snapshot list ct/<CT_ID> --repository <full_repo_string>
|
||||||
const command = `timeout 30 proxmox-backup-client snapshot list ct/${ctId} --repository ${repository} 2>&1 || echo "PBS_ERROR"`;
|
const command = `${snapshotEnvStr}timeout 30 proxmox-backup-client snapshot list ct/${ctId} --repository ${repository} 2>&1 || echo "PBS_ERROR"`;
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
||||||
console.log(`[BackupService] Discovering PBS backups for CT ${ctId} on repository ${repository}`);
|
console.log(`[BackupService] Discovering PBS backups for CT ${ctId} on repository ${repository}`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// JavaScript wrapper for githubJsonService (for use with node server.js)
|
// JavaScript wrapper for githubJsonService (for use with node server.js)
|
||||||
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
import { writeFile, mkdir, readdir, readFile, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { repositoryService } from './repositoryService.js';
|
import { repositoryService } from './repositoryService.js';
|
||||||
import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js';
|
import { listDirectory, downloadRawFile } from '../lib/gitProvider/index.js';
|
||||||
@@ -163,25 +163,42 @@ class GitHubJsonService {
|
|||||||
const localFiles = await this.getLocalJsonFiles();
|
const localFiles = await this.getLocalJsonFiles();
|
||||||
console.log(`Found ${localFiles.length} local JSON files`);
|
console.log(`Found ${localFiles.length} local JSON files`);
|
||||||
|
|
||||||
|
// Delete local JSON files that belong to this repo but are no longer in the remote
|
||||||
|
const remoteFilenames = new Set(githubFiles.map((f) => f.name));
|
||||||
|
const deletedFiles = await this.deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames);
|
||||||
|
if (deletedFiles.length > 0) {
|
||||||
|
console.log(`Removed ${deletedFiles.length} obsolete JSON file(s) no longer in ${repoUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
||||||
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
||||||
|
|
||||||
if (filesToSync.length === 0) {
|
if (filesToSync.length === 0) {
|
||||||
|
const msg =
|
||||||
|
deletedFiles.length > 0
|
||||||
|
? `All JSON files are up to date for repository: ${repoUrl}. Removed ${deletedFiles.length} obsolete file(s).`
|
||||||
|
: `All JSON files are up to date for repository: ${repoUrl}`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `All JSON files are up to date for repository: ${repoUrl}`,
|
message: msg,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
deletedFiles.length > 0
|
||||||
|
? `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}, removed ${deletedFiles.length} obsolete file(s).`
|
||||||
|
: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
|
message: msg,
|
||||||
count: syncedFiles.length,
|
count: syncedFiles.length,
|
||||||
syncedFiles
|
syncedFiles,
|
||||||
|
deletedFiles
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
||||||
@@ -189,7 +206,8 @@ class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,13 +223,15 @@ class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: 'No enabled repositories found',
|
message: 'No enabled repositories found',
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
||||||
|
|
||||||
const allSyncedFiles = [];
|
const allSyncedFiles = [];
|
||||||
|
const allDeletedFiles = [];
|
||||||
const processedSlugs = new Set();
|
const processedSlugs = new Set();
|
||||||
let totalSynced = 0;
|
let totalSynced = 0;
|
||||||
|
|
||||||
@@ -222,6 +242,7 @@ class GitHubJsonService {
|
|||||||
const result = await this.syncJsonFilesForRepo(repo.url);
|
const result = await this.syncJsonFilesForRepo(repo.url);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
allDeletedFiles.push(...(result.deletedFiles ?? []));
|
||||||
const newFiles = result.syncedFiles.filter(file => {
|
const newFiles = result.syncedFiles.filter(file => {
|
||||||
const slug = file.replace('.json', '');
|
const slug = file.replace('.json', '');
|
||||||
if (processedSlugs.has(slug)) {
|
if (processedSlugs.has(slug)) {
|
||||||
@@ -243,11 +264,16 @@ class GitHubJsonService {
|
|||||||
|
|
||||||
await this.updateExistingFilesWithRepositoryUrl();
|
await this.updateExistingFilesWithRepositoryUrl();
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
allDeletedFiles.length > 0
|
||||||
|
? `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories, removed ${allDeletedFiles.length} obsolete file(s).`
|
||||||
|
: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
|
message: msg,
|
||||||
count: totalSynced,
|
count: totalSynced,
|
||||||
syncedFiles: allSyncedFiles
|
syncedFiles: allSyncedFiles,
|
||||||
|
deletedFiles: allDeletedFiles
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Multi-repository JSON sync failed:', error);
|
console.error('Multi-repository JSON sync failed:', error);
|
||||||
@@ -255,7 +281,8 @@ class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,6 +324,32 @@ class GitHubJsonService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames) {
|
||||||
|
this.initializeConfig();
|
||||||
|
const localFiles = await this.getLocalJsonFiles();
|
||||||
|
const deletedFiles = [];
|
||||||
|
|
||||||
|
for (const file of localFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(this.localJsonDirectory, file);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content);
|
||||||
|
|
||||||
|
if (script.repository_url === repoUrl && !remoteFilenames.has(file)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
const slug = file.replace(/\.json$/, '');
|
||||||
|
this.scriptCache.delete(slug);
|
||||||
|
deletedFiles.push(file);
|
||||||
|
console.log(`Removed obsolete script JSON: ${file} (no longer in ${repoUrl})`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't read or parse the file, skip (do not delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
|
async findFilesToSyncForRepo(repoUrl, githubFiles, localFiles) {
|
||||||
const filesToSync = [];
|
const filesToSync = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { writeFile, mkdir, readdir, readFile } from 'fs/promises';
|
import { writeFile, mkdir, readdir, readFile, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
|
import type { Script, ScriptCard, GitHubFile } from '../../types/script';
|
||||||
@@ -158,7 +158,7 @@ export class GitHubJsonService {
|
|||||||
/**
|
/**
|
||||||
* Sync JSON files from a specific repository
|
* Sync JSON files from a specific repository
|
||||||
*/
|
*/
|
||||||
async syncJsonFilesForRepo(repoUrl: string): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
|
async syncJsonFilesForRepo(repoUrl: string): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[]; deletedFiles: string[] }> {
|
||||||
try {
|
try {
|
||||||
console.log(`Starting JSON sync from repository: ${repoUrl}`);
|
console.log(`Starting JSON sync from repository: ${repoUrl}`);
|
||||||
|
|
||||||
@@ -170,28 +170,45 @@ export class GitHubJsonService {
|
|||||||
const localFiles = await this.getLocalJsonFiles();
|
const localFiles = await this.getLocalJsonFiles();
|
||||||
console.log(`Found ${localFiles.length} local JSON files`);
|
console.log(`Found ${localFiles.length} local JSON files`);
|
||||||
|
|
||||||
|
// Delete local JSON files that belong to this repo but are no longer in the remote
|
||||||
|
const remoteFilenames = new Set(githubFiles.map((f) => f.name));
|
||||||
|
const deletedFiles = await this.deleteLocalFilesRemovedFromRepo(repoUrl, remoteFilenames);
|
||||||
|
if (deletedFiles.length > 0) {
|
||||||
|
console.log(`Removed ${deletedFiles.length} obsolete JSON file(s) no longer in ${repoUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Compare and find files that need syncing
|
// Compare and find files that need syncing
|
||||||
// For multi-repo support, we need to check if file exists AND if it's from this repo
|
// For multi-repo support, we need to check if file exists AND if it's from this repo
|
||||||
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
const filesToSync = await this.findFilesToSyncForRepo(repoUrl, githubFiles, localFiles);
|
||||||
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
console.log(`Found ${filesToSync.length} files that need syncing from ${repoUrl}`);
|
||||||
|
|
||||||
if (filesToSync.length === 0) {
|
if (filesToSync.length === 0) {
|
||||||
|
const msg =
|
||||||
|
deletedFiles.length > 0
|
||||||
|
? `All JSON files are up to date for repository: ${repoUrl}. Removed ${deletedFiles.length} obsolete file(s).`
|
||||||
|
: `All JSON files are up to date for repository: ${repoUrl}`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `All JSON files are up to date for repository: ${repoUrl}`,
|
message: msg,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and save only the files that need syncing
|
// Download and save only the files that need syncing
|
||||||
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
const syncedFiles = await this.syncSpecificFiles(repoUrl, filesToSync);
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
deletedFiles.length > 0
|
||||||
|
? `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}, removed ${deletedFiles.length} obsolete file(s).`
|
||||||
|
: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully synced ${syncedFiles.length} JSON files from ${repoUrl}`,
|
message: msg,
|
||||||
count: syncedFiles.length,
|
count: syncedFiles.length,
|
||||||
syncedFiles
|
syncedFiles,
|
||||||
|
deletedFiles
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
console.error(`JSON sync failed for ${repoUrl}:`, error);
|
||||||
@@ -199,7 +216,8 @@ export class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Failed to sync JSON files from ${repoUrl}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +225,7 @@ export class GitHubJsonService {
|
|||||||
/**
|
/**
|
||||||
* Sync JSON files from all enabled repositories (main repo has priority)
|
* Sync JSON files from all enabled repositories (main repo has priority)
|
||||||
*/
|
*/
|
||||||
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[] }> {
|
async syncJsonFiles(): Promise<{ success: boolean; message: string; count: number; syncedFiles: string[]; deletedFiles: string[] }> {
|
||||||
try {
|
try {
|
||||||
console.log('Starting multi-repository JSON sync...');
|
console.log('Starting multi-repository JSON sync...');
|
||||||
|
|
||||||
@@ -218,13 +236,15 @@ export class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: 'No enabled repositories found',
|
message: 'No enabled repositories found',
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
console.log(`Found ${enabledRepos.length} enabled repositories`);
|
||||||
|
|
||||||
const allSyncedFiles: string[] = [];
|
const allSyncedFiles: string[] = [];
|
||||||
|
const allDeletedFiles: string[] = [];
|
||||||
const processedSlugs = new Set<string>(); // Track slugs we've already processed
|
const processedSlugs = new Set<string>(); // Track slugs we've already processed
|
||||||
let totalSynced = 0;
|
let totalSynced = 0;
|
||||||
|
|
||||||
@@ -236,6 +256,7 @@ export class GitHubJsonService {
|
|||||||
const result = await this.syncJsonFilesForRepo(repo.url);
|
const result = await this.syncJsonFilesForRepo(repo.url);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
allDeletedFiles.push(...(result.deletedFiles ?? []));
|
||||||
// Only count files that weren't already processed from a higher priority repo
|
// Only count files that weren't already processed from a higher priority repo
|
||||||
const newFiles = result.syncedFiles.filter(file => {
|
const newFiles = result.syncedFiles.filter(file => {
|
||||||
const slug = file.replace('.json', '');
|
const slug = file.replace('.json', '');
|
||||||
@@ -259,11 +280,16 @@ export class GitHubJsonService {
|
|||||||
// Also update existing files that don't have repository_url set (backward compatibility)
|
// Also update existing files that don't have repository_url set (backward compatibility)
|
||||||
await this.updateExistingFilesWithRepositoryUrl();
|
await this.updateExistingFilesWithRepositoryUrl();
|
||||||
|
|
||||||
|
const msg =
|
||||||
|
allDeletedFiles.length > 0
|
||||||
|
? `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories, removed ${allDeletedFiles.length} obsolete file(s).`
|
||||||
|
: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Successfully synced ${totalSynced} JSON files from ${enabledRepos.length} repositories`,
|
message: msg,
|
||||||
count: totalSynced,
|
count: totalSynced,
|
||||||
syncedFiles: allSyncedFiles
|
syncedFiles: allSyncedFiles,
|
||||||
|
deletedFiles: allDeletedFiles
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Multi-repository JSON sync failed:', error);
|
console.error('Multi-repository JSON sync failed:', error);
|
||||||
@@ -271,7 +297,8 @@ export class GitHubJsonService {
|
|||||||
success: false,
|
success: false,
|
||||||
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
message: `Failed to sync JSON files: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
count: 0,
|
count: 0,
|
||||||
syncedFiles: []
|
syncedFiles: [],
|
||||||
|
deletedFiles: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,6 +343,36 @@ export class GitHubJsonService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete local JSON files that belong to this repo but are no longer in the remote list.
|
||||||
|
* Returns the list of deleted filenames.
|
||||||
|
*/
|
||||||
|
private async deleteLocalFilesRemovedFromRepo(repoUrl: string, remoteFilenames: Set<string>): Promise<string[]> {
|
||||||
|
this.initializeConfig();
|
||||||
|
const localFiles = await this.getLocalJsonFiles();
|
||||||
|
const deletedFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const file of localFiles) {
|
||||||
|
try {
|
||||||
|
const filePath = join(this.localJsonDirectory!, file);
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
const script = JSON.parse(content) as Script;
|
||||||
|
|
||||||
|
if (script.repository_url === repoUrl && !remoteFilenames.has(file)) {
|
||||||
|
await unlink(filePath);
|
||||||
|
const slug = file.replace(/\.json$/, '');
|
||||||
|
this.scriptCache.delete(slug);
|
||||||
|
deletedFiles.push(file);
|
||||||
|
console.log(`Removed obsolete script JSON: ${file} (no longer in ${repoUrl})`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't read or parse the file, skip (do not delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find files that need syncing for a specific repository
|
* Find files that need syncing for a specific repository
|
||||||
* This checks if file exists locally AND if it's from the same repository
|
* This checks if file exists locally AND if it's from the same repository
|
||||||
|
|||||||
@@ -250,9 +250,16 @@ class RestoreService {
|
|||||||
const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
|
const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`;
|
||||||
const targetTar = `${targetFolder}.tar`;
|
const targetTar = `${targetFolder}.tar`;
|
||||||
|
|
||||||
// Use PBS_PASSWORD env var and add timeout for long downloads
|
// Use PBS_PASSWORD env var and add timeout for long downloads; PBS_FINGERPRINT when set for cert validation
|
||||||
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''");
|
||||||
const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
|
const fingerprint = credential.pbs_fingerprint?.trim() ?? '';
|
||||||
|
const escapedFingerprint = fingerprint ? fingerprint.replace(/'/g, "'\\''") : '';
|
||||||
|
const restoreEnvParts = [`PBS_PASSWORD='${escapedPassword}'`, `PBS_REPOSITORY='${repository}'`];
|
||||||
|
if (escapedFingerprint) {
|
||||||
|
restoreEnvParts.push(`PBS_FINGERPRINT='${escapedFingerprint}'`);
|
||||||
|
}
|
||||||
|
const restoreEnvStr = restoreEnvParts.join(' ');
|
||||||
|
const restoreCommand = `${restoreEnvStr} timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`;
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
let exitCode = 0;
|
let exitCode = 0;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { spawn as ptySpawn } from 'node-pty';
|
import { spawn as ptySpawn } from 'node-pty';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,9 +197,22 @@ class SSHExecutionService {
|
|||||||
async transferScriptsFolder(server, onData, onError) {
|
async transferScriptsFolder(server, onData, onError) {
|
||||||
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const cleanupTempFile = (/** @type {string | null} */ tempPath) => {
|
||||||
|
if (tempPath) {
|
||||||
try {
|
try {
|
||||||
// Build rsync command based on authentication type
|
unlinkSync(tempPath);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
/** @type {string | null} */
|
||||||
|
let tempPath = null;
|
||||||
|
try {
|
||||||
|
// Build rsync command based on authentication type.
|
||||||
|
// Use sshpass -f with a temp file so password/passphrase never go through the shell (safe for special chars like {, $, ").
|
||||||
let rshCommand;
|
let rshCommand;
|
||||||
if (auth_type === 'key') {
|
if (auth_type === 'key') {
|
||||||
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
if (!ssh_key_path || !existsSync(ssh_key_path)) {
|
||||||
@@ -205,13 +220,19 @@ class SSHExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ssh_key_passphrase) {
|
if (ssh_key_passphrase) {
|
||||||
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
|
||||||
|
writeFileSync(tempPath, ssh_key_passphrase);
|
||||||
|
chmodSync(tempPath, 0o600);
|
||||||
|
rshCommand = `sshpass -P passphrase -f ${tempPath} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
} else {
|
} else {
|
||||||
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Password authentication
|
// Password authentication
|
||||||
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
|
||||||
|
writeFileSync(tempPath, password ?? '');
|
||||||
|
chmodSync(tempPath, 0o600);
|
||||||
|
rshCommand = `sshpass -f ${tempPath} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rsyncCommand = spawn('rsync', [
|
const rsyncCommand = spawn('rsync', [
|
||||||
@@ -227,18 +248,17 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
|
||||||
// Ensure proper UTF-8 encoding for ANSI colors
|
|
||||||
const output = data.toString('utf8');
|
const output = data.toString('utf8');
|
||||||
onData(output);
|
onData(output);
|
||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
|
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
|
||||||
// Ensure proper UTF-8 encoding for ANSI colors
|
|
||||||
const output = data.toString('utf8');
|
const output = data.toString('utf8');
|
||||||
onError(output);
|
onError(output);
|
||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('close', (code) => {
|
rsyncCommand.on('close', (code) => {
|
||||||
|
cleanupTempFile(tempPath);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
@@ -247,10 +267,11 @@ class SSHExecutionService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsyncCommand.on('error', (error) => {
|
rsyncCommand.on('error', (error) => {
|
||||||
|
cleanupTempFile(tempPath);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
cleanupTempFile(tempPath);
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,16 +169,17 @@ class SSHService {
|
|||||||
const timeout = 10000;
|
const timeout = 10000;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
|
// Pass password via env so it is not embedded in the script (safe for special chars like {, $, ").
|
||||||
const expectScript = `#!/usr/bin/expect -f
|
const expectScript = `#!/usr/bin/expect -f
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
|
||||||
expect {
|
expect {
|
||||||
"password:" {
|
"password:" {
|
||||||
send "${password}\r"
|
send "$env(SSH_PASSWORD)\\r"
|
||||||
exp_continue
|
exp_continue
|
||||||
}
|
}
|
||||||
"Password:" {
|
"Password:" {
|
||||||
send "${password}\r"
|
send "$env(SSH_PASSWORD)\\r"
|
||||||
exp_continue
|
exp_continue
|
||||||
}
|
}
|
||||||
"SSH_LOGIN_SUCCESS" {
|
"SSH_LOGIN_SUCCESS" {
|
||||||
@@ -193,7 +194,8 @@ expect {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
const expectCommand = spawn('expect', ['-c', expectScript], {
|
const expectCommand = spawn('expect', ['-c', expectScript], {
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, SSH_PASSWORD: password ?? '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user