easy-vps 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +180 -0
- package/client/assets/index-C4GZ_uMC.js +104 -0
- package/client/assets/index-Dq-W19hL.css +2 -0
- package/client/assets/poppins-devanagari-400-normal-CJDn6rn8.woff2 +0 -0
- package/client/assets/poppins-devanagari-400-normal-CqVvlrh5.woff +0 -0
- package/client/assets/poppins-latin-400-normal-BOb3E3N0.woff +0 -0
- package/client/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/client/assets/poppins-latin-ext-400-normal-DaBSavcJ.woff +0 -0
- package/client/assets/poppins-latin-ext-400-normal-by3JarPu.woff2 +0 -0
- package/client/favicon.svg +1 -0
- package/client/icons.svg +24 -0
- package/client/index.html +14 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +143 -0
- package/dist/routes/async-route.d.ts +3 -0
- package/dist/routes/async-route.js +9 -0
- package/dist/routes/auth.d.ts +5 -0
- package/dist/routes/auth.js +101 -0
- package/dist/routes/database.d.ts +2 -0
- package/dist/routes/database.js +192 -0
- package/dist/routes/deploy.d.ts +2 -0
- package/dist/routes/deploy.js +161 -0
- package/dist/routes/domain.d.ts +2 -0
- package/dist/routes/domain.js +54 -0
- package/dist/routes/firewall.d.ts +2 -0
- package/dist/routes/firewall.js +63 -0
- package/dist/routes/instances.d.ts +2 -0
- package/dist/routes/instances.js +77 -0
- package/dist/routes/logs.d.ts +2 -0
- package/dist/routes/logs.js +67 -0
- package/dist/routes/system.d.ts +2 -0
- package/dist/routes/system.js +83 -0
- package/dist/services/auth.d.ts +25 -0
- package/dist/services/auth.js +128 -0
- package/dist/services/backup-config.d.ts +16 -0
- package/dist/services/backup-config.js +51 -0
- package/dist/services/backup-scheduler.d.ts +2 -0
- package/dist/services/backup-scheduler.js +104 -0
- package/dist/services/daemon.d.ts +28 -0
- package/dist/services/daemon.js +180 -0
- package/dist/services/database.d.ts +60 -0
- package/dist/services/database.js +540 -0
- package/dist/services/deploy.d.ts +102 -0
- package/dist/services/deploy.js +540 -0
- package/dist/services/domain.d.ts +26 -0
- package/dist/services/domain.js +170 -0
- package/dist/services/firewall.d.ts +24 -0
- package/dist/services/firewall.js +64 -0
- package/dist/services/instances.d.ts +17 -0
- package/dist/services/instances.js +67 -0
- package/dist/services/logs.d.ts +8 -0
- package/dist/services/logs.js +61 -0
- package/dist/services/metrics-history.d.ts +13 -0
- package/dist/services/metrics-history.js +36 -0
- package/dist/services/packages.d.ts +22 -0
- package/dist/services/packages.js +124 -0
- package/dist/services/s3.d.ts +17 -0
- package/dist/services/s3.js +93 -0
- package/dist/services/ssh.d.ts +78 -0
- package/dist/services/ssh.js +309 -0
- package/dist/services/system.d.ts +74 -0
- package/dist/services/system.js +286 -0
- package/package.json +55 -0
- package/scripts/dev.js +88 -0
- package/scripts/postinstall.js +123 -0
- package/scripts/preuninstall.js +21 -0
- package/scripts/try-install.sh +65 -0
- package/scripts/ui.js +54 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const express_1 = require("express");
|
|
37
|
+
const system = __importStar(require("../services/system"));
|
|
38
|
+
const metrics_history_1 = require("../services/metrics-history");
|
|
39
|
+
const async_route_1 = require("./async-route");
|
|
40
|
+
const router = (0, express_1.Router)();
|
|
41
|
+
router.get('/status', (0, async_route_1.asyncRoute)(async (req, res) => {
|
|
42
|
+
const [privileged, dependencies] = await Promise.all([
|
|
43
|
+
system.canAdminister(req.remote),
|
|
44
|
+
system.detectAll(req.remote),
|
|
45
|
+
]);
|
|
46
|
+
res.json({
|
|
47
|
+
root: privileged,
|
|
48
|
+
host: req.remote.host,
|
|
49
|
+
user: req.remote.username,
|
|
50
|
+
dependencies,
|
|
51
|
+
});
|
|
52
|
+
}));
|
|
53
|
+
router.get('/metrics', (0, async_route_1.asyncRoute)(async (req, res) => {
|
|
54
|
+
const metrics = await system.getMetrics(req.remote);
|
|
55
|
+
(0, metrics_history_1.recordLoad)(req.remote.host, metrics.loadAverage);
|
|
56
|
+
res.json(metrics);
|
|
57
|
+
}));
|
|
58
|
+
router.get('/metrics/history', (0, async_route_1.asyncRoute)(async (req, res) => {
|
|
59
|
+
res.json((0, metrics_history_1.getLoadHistory)(req.remote.host));
|
|
60
|
+
}));
|
|
61
|
+
router.get('/:action(install|uninstall)/:name/stream', (req, res) => {
|
|
62
|
+
res.writeHead(200, {
|
|
63
|
+
'Content-Type': 'text/event-stream',
|
|
64
|
+
'Cache-Control': 'no-cache',
|
|
65
|
+
Connection: 'keep-alive',
|
|
66
|
+
});
|
|
67
|
+
const send = (event, data) => {
|
|
68
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
69
|
+
};
|
|
70
|
+
const cancel = system.packageStream(req.remote, req.params.name, req.params.action, {
|
|
71
|
+
onOutput: (line) => send('log', line),
|
|
72
|
+
onDone: () => {
|
|
73
|
+
send('done', 'ok');
|
|
74
|
+
res.end();
|
|
75
|
+
},
|
|
76
|
+
onError: (message) => {
|
|
77
|
+
send('failed', message);
|
|
78
|
+
res.end();
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
req.on('close', cancel);
|
|
82
|
+
});
|
|
83
|
+
exports.default = router;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Credentials, type Remote } from './ssh';
|
|
2
|
+
export declare class ValidationError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare class AuthError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export type LoginCredentials = Credentials;
|
|
7
|
+
export interface Session {
|
|
8
|
+
/** Key into the in-memory connection registry in ./ssh. */
|
|
9
|
+
id: string;
|
|
10
|
+
username: string;
|
|
11
|
+
host: string;
|
|
12
|
+
issuedAt: number;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function secretPath(): string;
|
|
16
|
+
export declare function verify(token: string): Session | null;
|
|
17
|
+
/**
|
|
18
|
+
* Verifies credentials against the VPS's sshd and, on success, registers the
|
|
19
|
+
* live connection the rest of the panel runs its commands through.
|
|
20
|
+
*/
|
|
21
|
+
export declare function login(credentials: LoginCredentials): Promise<string>;
|
|
22
|
+
/** Closes the SSH connection behind a session token, if one is still open. */
|
|
23
|
+
export declare function logout(token: string): void;
|
|
24
|
+
/** The live connection for a verified session, or null once the panel restarts. */
|
|
25
|
+
export declare function remoteFor(session: Session): Remote | null;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AuthError = exports.ValidationError = void 0;
|
|
7
|
+
exports.secretPath = secretPath;
|
|
8
|
+
exports.verify = verify;
|
|
9
|
+
exports.login = login;
|
|
10
|
+
exports.logout = logout;
|
|
11
|
+
exports.remoteFor = remoteFor;
|
|
12
|
+
// Verifies VPS credentials by attempting a real SSH connection, and issues session tokens.
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const daemon_1 = require("./daemon");
|
|
17
|
+
const ssh_1 = require("./ssh");
|
|
18
|
+
class ValidationError extends Error {
|
|
19
|
+
}
|
|
20
|
+
exports.ValidationError = ValidationError;
|
|
21
|
+
class AuthError extends Error {
|
|
22
|
+
}
|
|
23
|
+
exports.AuthError = AuthError;
|
|
24
|
+
const SECRET_BYTES = 32;
|
|
25
|
+
const SESSION_LIFETIME_SECONDS = 12 * 60 * 60;
|
|
26
|
+
function secretPath() {
|
|
27
|
+
return node_path_1.default.join((0, daemon_1.stateDir)(), 'session-secret');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The HMAC key for session cookies. Kept on disk so a restart (a `tsc --watch`
|
|
31
|
+
* rebuild in dev, a `systemctl restart` in production) does not silently
|
|
32
|
+
* invalidate every issued cookie and drop the UI to 401.
|
|
33
|
+
*/
|
|
34
|
+
function loadSecret() {
|
|
35
|
+
const fromEnv = process.env.EASY_VPS_SECRET;
|
|
36
|
+
if (fromEnv)
|
|
37
|
+
return (0, node_crypto_1.createHash)('sha256').update(fromEnv).digest();
|
|
38
|
+
let file;
|
|
39
|
+
try {
|
|
40
|
+
file = secretPath();
|
|
41
|
+
const secret = Buffer.from((0, node_fs_1.readFileSync)(file, 'utf8').trim(), 'base64');
|
|
42
|
+
if (secret.length === SECRET_BYTES)
|
|
43
|
+
return secret;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// No secret yet, or the state directory is unreachable — mint a new one below.
|
|
47
|
+
}
|
|
48
|
+
const secret = (0, node_crypto_1.randomBytes)(SECRET_BYTES);
|
|
49
|
+
try {
|
|
50
|
+
file ??= secretPath();
|
|
51
|
+
(0, node_fs_1.writeFileSync)(file, secret.toString('base64'), { mode: 0o600 });
|
|
52
|
+
(0, node_fs_1.chmodSync)(file, 0o600);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.warn(`easy-vps: could not persist the session secret (${err.message}); ` +
|
|
56
|
+
'sessions will not survive a restart. Set EASY_VPS_SECRET to pin one.');
|
|
57
|
+
}
|
|
58
|
+
return secret;
|
|
59
|
+
}
|
|
60
|
+
const SECRET = loadSecret();
|
|
61
|
+
function sign(session) {
|
|
62
|
+
const payload = Buffer.from(JSON.stringify(session)).toString('base64url');
|
|
63
|
+
const signature = (0, node_crypto_1.createHmac)('sha256', SECRET).update(payload).digest('base64url');
|
|
64
|
+
return `${payload}.${signature}`;
|
|
65
|
+
}
|
|
66
|
+
function verify(token) {
|
|
67
|
+
const parts = token.split('.');
|
|
68
|
+
if (parts.length !== 2)
|
|
69
|
+
return null;
|
|
70
|
+
const [payload, signature] = parts;
|
|
71
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', SECRET).update(payload).digest('base64url');
|
|
72
|
+
const a = Buffer.from(signature);
|
|
73
|
+
const b = Buffer.from(expected);
|
|
74
|
+
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
|
|
75
|
+
return null;
|
|
76
|
+
try {
|
|
77
|
+
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
78
|
+
if (Date.now() / 1000 > session.expiresAt)
|
|
79
|
+
return null;
|
|
80
|
+
return session;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Verifies credentials against the VPS's sshd and, on success, registers the
|
|
88
|
+
* live connection the rest of the panel runs its commands through.
|
|
89
|
+
*/
|
|
90
|
+
async function login(credentials) {
|
|
91
|
+
if (!credentials.host || !credentials.username) {
|
|
92
|
+
throw new ValidationError('Host and username are required');
|
|
93
|
+
}
|
|
94
|
+
const hasPassword = Boolean(credentials.password);
|
|
95
|
+
const hasKey = Boolean(credentials.privateKey);
|
|
96
|
+
if (hasPassword === hasKey) {
|
|
97
|
+
throw new ValidationError('Provide either a password or a private key, not both');
|
|
98
|
+
}
|
|
99
|
+
const id = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
100
|
+
const remote = (0, ssh_1.createSession)(id, credentials);
|
|
101
|
+
try {
|
|
102
|
+
// Doubles as the credential check and as a warm-up of the uid cache.
|
|
103
|
+
await remote.uid();
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
(0, ssh_1.destroySession)(id);
|
|
107
|
+
throw new AuthError(err instanceof ssh_1.RemoteError ? err.message : String(err));
|
|
108
|
+
}
|
|
109
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
110
|
+
const session = {
|
|
111
|
+
id,
|
|
112
|
+
username: credentials.username,
|
|
113
|
+
host: credentials.host,
|
|
114
|
+
issuedAt,
|
|
115
|
+
expiresAt: issuedAt + SESSION_LIFETIME_SECONDS,
|
|
116
|
+
};
|
|
117
|
+
return sign(session);
|
|
118
|
+
}
|
|
119
|
+
/** Closes the SSH connection behind a session token, if one is still open. */
|
|
120
|
+
function logout(token) {
|
|
121
|
+
const session = verify(token);
|
|
122
|
+
if (session)
|
|
123
|
+
(0, ssh_1.destroySession)(session.id);
|
|
124
|
+
}
|
|
125
|
+
/** The live connection for a verified session, or null once the panel restarts. */
|
|
126
|
+
function remoteFor(session) {
|
|
127
|
+
return (0, ssh_1.getSession)(session.id);
|
|
128
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Credentials } from './ssh';
|
|
2
|
+
export interface S3Config {
|
|
3
|
+
bucket: string;
|
|
4
|
+
region: string;
|
|
5
|
+
accessKey: string;
|
|
6
|
+
secretKey: string;
|
|
7
|
+
}
|
|
8
|
+
export interface BackupConfig {
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
retention: number;
|
|
11
|
+
s3: S3Config | null;
|
|
12
|
+
ssh: Credentials | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function getBackupConfig(name: string): BackupConfig;
|
|
15
|
+
export declare function setBackupConfig(name: string, config: Partial<BackupConfig>): BackupConfig;
|
|
16
|
+
export declare function getAllEnabled(): Record<string, BackupConfig>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getBackupConfig = getBackupConfig;
|
|
7
|
+
exports.setBackupConfig = setBackupConfig;
|
|
8
|
+
exports.getAllEnabled = getAllEnabled;
|
|
9
|
+
// Persistent backup configuration stored in the panel's state directory.
|
|
10
|
+
const node_fs_1 = require("node:fs");
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const daemon_1 = require("./daemon");
|
|
13
|
+
function configPath() {
|
|
14
|
+
return node_path_1.default.join((0, daemon_1.stateDir)(), 'backup-config.json');
|
|
15
|
+
}
|
|
16
|
+
function loadStore() {
|
|
17
|
+
try {
|
|
18
|
+
if ((0, node_fs_1.existsSync)(configPath())) {
|
|
19
|
+
return JSON.parse((0, node_fs_1.readFileSync)(configPath(), 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Corrupted file — start fresh
|
|
24
|
+
}
|
|
25
|
+
return { databases: {} };
|
|
26
|
+
}
|
|
27
|
+
function saveStore(store) {
|
|
28
|
+
(0, node_fs_1.writeFileSync)(configPath(), JSON.stringify(store, null, 2), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
function getBackupConfig(name) {
|
|
31
|
+
const store = loadStore();
|
|
32
|
+
return store.databases[name] ?? { enabled: false, retention: 7, s3: null, ssh: null };
|
|
33
|
+
}
|
|
34
|
+
function setBackupConfig(name, config) {
|
|
35
|
+
const store = loadStore();
|
|
36
|
+
const existing = store.databases[name] ?? { enabled: false, retention: 7, s3: null, ssh: null };
|
|
37
|
+
const merged = { ...existing, ...config };
|
|
38
|
+
store.databases[name] = merged;
|
|
39
|
+
saveStore(store);
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
function getAllEnabled() {
|
|
43
|
+
const store = loadStore();
|
|
44
|
+
const enabled = {};
|
|
45
|
+
for (const [name, config] of Object.entries(store.databases)) {
|
|
46
|
+
if (config.enabled && config.s3) {
|
|
47
|
+
enabled[name] = config;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return enabled;
|
|
51
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.startBackupScheduler = startBackupScheduler;
|
|
37
|
+
exports.stopBackupScheduler = stopBackupScheduler;
|
|
38
|
+
// Hourly backup scheduler — runs independently of user sessions.
|
|
39
|
+
const cron = __importStar(require("node-cron"));
|
|
40
|
+
const ssh_1 = require("./ssh");
|
|
41
|
+
const backup_config_1 = require("./backup-config");
|
|
42
|
+
const database_1 = require("./database");
|
|
43
|
+
const s3_1 = require("./s3");
|
|
44
|
+
let task = null;
|
|
45
|
+
async function runBackupCycle() {
|
|
46
|
+
const enabled = (0, backup_config_1.getAllEnabled)();
|
|
47
|
+
const entries = Object.entries(enabled);
|
|
48
|
+
if (entries.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
console.log(`[backup-scheduler] Running backup cycle for ${entries.length} database(s)…`);
|
|
51
|
+
for (const [name, config] of entries) {
|
|
52
|
+
if (!config.ssh || !config.s3)
|
|
53
|
+
continue;
|
|
54
|
+
const sessionId = `backup-${name}-${Date.now()}`;
|
|
55
|
+
let remote = null;
|
|
56
|
+
try {
|
|
57
|
+
remote = (0, ssh_1.createSession)(sessionId, config.ssh);
|
|
58
|
+
// Ensure the connection works
|
|
59
|
+
await remote.uid();
|
|
60
|
+
console.log(`[backup-scheduler] Backing up ${name}…`);
|
|
61
|
+
const { key } = await (0, database_1.backupToS3)(remote, name, 'postgres');
|
|
62
|
+
console.log(`[backup-scheduler] ${name} uploaded to s3://${config.s3.bucket}/${key}`);
|
|
63
|
+
// Enforce retention
|
|
64
|
+
await (0, s3_1.enforceRetention)(config.s3, 'postgres', name, config.retention);
|
|
65
|
+
console.log(`[backup-scheduler] ${name} retention enforced (keep ${config.retention})`);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`[backup-scheduler] Failed to backup ${name}:`, err.message);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (remote) {
|
|
72
|
+
try {
|
|
73
|
+
(0, ssh_1.destroySession)(sessionId);
|
|
74
|
+
}
|
|
75
|
+
catch { /* ignore */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
console.log('[backup-scheduler] Backup cycle complete');
|
|
80
|
+
}
|
|
81
|
+
function startBackupScheduler() {
|
|
82
|
+
if (task)
|
|
83
|
+
return;
|
|
84
|
+
console.log('[backup-scheduler] Starting hourly backup scheduler (cron: 0 * * * *)');
|
|
85
|
+
// Run first cycle after 1 minute (give server time to fully start)
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
runBackupCycle().catch((err) => {
|
|
88
|
+
console.error('[backup-scheduler] Initial cycle failed:', err.message);
|
|
89
|
+
});
|
|
90
|
+
}, 60_000);
|
|
91
|
+
// Schedule hourly cron job: at minute 0 of every hour
|
|
92
|
+
task = cron.schedule('0 * * * *', () => {
|
|
93
|
+
runBackupCycle().catch((err) => {
|
|
94
|
+
console.error('[backup-scheduler] Scheduled cycle failed:', err.message);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function stopBackupScheduler() {
|
|
99
|
+
if (task) {
|
|
100
|
+
task.stop();
|
|
101
|
+
task = null;
|
|
102
|
+
console.log('[backup-scheduler] Scheduler stopped');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const SERVICE_NAME = "easy-vps";
|
|
2
|
+
export type Mode = 'systemd' | 'process';
|
|
3
|
+
export interface DaemonOptions {
|
|
4
|
+
port: number;
|
|
5
|
+
host: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DaemonState {
|
|
8
|
+
mode: Mode | null;
|
|
9
|
+
running: boolean;
|
|
10
|
+
pid: number | null;
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function isRoot(): boolean;
|
|
14
|
+
export declare function hasSystemd(): boolean;
|
|
15
|
+
/** systemd is only usable for a system-wide unit when we are root. */
|
|
16
|
+
export declare function mode(): Mode;
|
|
17
|
+
/** Writable location for the pid file and log of the detached fallback. */
|
|
18
|
+
export declare function stateDir(): string;
|
|
19
|
+
export declare function logPath(): string;
|
|
20
|
+
export declare function start(options: DaemonOptions): Promise<Mode>;
|
|
21
|
+
export declare function stop(): Promise<void>;
|
|
22
|
+
/** Remove the systemd unit; used when the package is uninstalled. */
|
|
23
|
+
export declare function uninstall(): Promise<void>;
|
|
24
|
+
export declare function status(): Promise<DaemonState>;
|
|
25
|
+
/** Open the panel port in ufw, when ufw is installed and active. */
|
|
26
|
+
export declare function openFirewall(port: number): Promise<boolean>;
|
|
27
|
+
/** Non-internal IPv4 addresses, used to tell the user where to point a browser. */
|
|
28
|
+
export declare function addresses(): string[];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SERVICE_NAME = void 0;
|
|
7
|
+
exports.isRoot = isRoot;
|
|
8
|
+
exports.hasSystemd = hasSystemd;
|
|
9
|
+
exports.mode = mode;
|
|
10
|
+
exports.stateDir = stateDir;
|
|
11
|
+
exports.logPath = logPath;
|
|
12
|
+
exports.start = start;
|
|
13
|
+
exports.stop = stop;
|
|
14
|
+
exports.uninstall = uninstall;
|
|
15
|
+
exports.status = status;
|
|
16
|
+
exports.openFirewall = openFirewall;
|
|
17
|
+
exports.addresses = addresses;
|
|
18
|
+
// Background service control: systemd unit when available, detached process otherwise.
|
|
19
|
+
const node_child_process_1 = require("node:child_process");
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
22
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
23
|
+
const node_util_1 = require("node:util");
|
|
24
|
+
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
25
|
+
exports.SERVICE_NAME = 'easy-vps';
|
|
26
|
+
const UNIT_PATH = `/etc/systemd/system/${exports.SERVICE_NAME}.service`;
|
|
27
|
+
function isRoot() {
|
|
28
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
29
|
+
}
|
|
30
|
+
function hasSystemd() {
|
|
31
|
+
return (0, node_fs_1.existsSync)('/run/systemd/system') && (0, node_fs_1.existsSync)('/usr/bin/systemctl');
|
|
32
|
+
}
|
|
33
|
+
/** systemd is only usable for a system-wide unit when we are root. */
|
|
34
|
+
function mode() {
|
|
35
|
+
return hasSystemd() && isRoot() ? 'systemd' : 'process';
|
|
36
|
+
}
|
|
37
|
+
/** Writable location for the pid file and log of the detached fallback. */
|
|
38
|
+
function stateDir() {
|
|
39
|
+
const dir = isRoot() ? '/var/lib/easy-vps' : node_path_1.default.join(node_os_1.default.homedir(), '.easy-vps');
|
|
40
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
function logPath() {
|
|
44
|
+
return node_path_1.default.join(stateDir(), 'easy-vps.log');
|
|
45
|
+
}
|
|
46
|
+
function pidPath() {
|
|
47
|
+
return node_path_1.default.join(stateDir(), 'easy-vps.pid');
|
|
48
|
+
}
|
|
49
|
+
function cliPath() {
|
|
50
|
+
return node_path_1.default.resolve(__dirname, '..', '..', 'bin', 'cli.js');
|
|
51
|
+
}
|
|
52
|
+
function readPid() {
|
|
53
|
+
try {
|
|
54
|
+
const pid = Number((0, node_fs_1.readFileSync)(pidPath(), 'utf8').trim());
|
|
55
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function alive(pid) {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function unitFile(options) {
|
|
71
|
+
return `[Unit]
|
|
72
|
+
Description=easy-vps control panel
|
|
73
|
+
After=network.target
|
|
74
|
+
|
|
75
|
+
[Service]
|
|
76
|
+
Type=simple
|
|
77
|
+
ExecStart=${process.execPath} ${cliPath()} --port ${options.port} --host ${options.host}
|
|
78
|
+
Restart=on-failure
|
|
79
|
+
RestartSec=5
|
|
80
|
+
Environment=NODE_ENV=production
|
|
81
|
+
|
|
82
|
+
[Install]
|
|
83
|
+
WantedBy=multi-user.target
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
/** Install and start the systemd unit so the panel survives a closed terminal and reboots. */
|
|
87
|
+
async function startSystemd(options) {
|
|
88
|
+
(0, node_fs_1.writeFileSync)(UNIT_PATH, unitFile(options), 'utf8');
|
|
89
|
+
await run('systemctl', ['daemon-reload']);
|
|
90
|
+
await run('systemctl', ['enable', exports.SERVICE_NAME]);
|
|
91
|
+
await run('systemctl', ['restart', exports.SERVICE_NAME]);
|
|
92
|
+
}
|
|
93
|
+
/** Fallback: fully detached child that outlives the parent shell. */
|
|
94
|
+
function startProcess(options) {
|
|
95
|
+
const out = (0, node_fs_1.openSync)(logPath(), 'a');
|
|
96
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [cliPath(), '--port', String(options.port), '--host', options.host], { detached: true, stdio: ['ignore', out, out] });
|
|
97
|
+
child.unref();
|
|
98
|
+
(0, node_fs_1.writeFileSync)(pidPath(), String(child.pid), 'utf8');
|
|
99
|
+
return child.pid;
|
|
100
|
+
}
|
|
101
|
+
async function start(options) {
|
|
102
|
+
const target = mode();
|
|
103
|
+
if (target === 'systemd') {
|
|
104
|
+
await startSystemd(options);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
await stop();
|
|
108
|
+
startProcess(options);
|
|
109
|
+
}
|
|
110
|
+
return target;
|
|
111
|
+
}
|
|
112
|
+
async function stop() {
|
|
113
|
+
if (mode() === 'systemd' && (0, node_fs_1.existsSync)(UNIT_PATH)) {
|
|
114
|
+
await run('systemctl', ['stop', exports.SERVICE_NAME]).catch(() => undefined);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const pid = readPid();
|
|
118
|
+
if (pid && alive(pid)) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 'SIGTERM');
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// already gone
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
(0, node_fs_1.rmSync)(pidPath(), { force: true });
|
|
127
|
+
}
|
|
128
|
+
/** Remove the systemd unit; used when the package is uninstalled. */
|
|
129
|
+
async function uninstall() {
|
|
130
|
+
await stop();
|
|
131
|
+
if ((0, node_fs_1.existsSync)(UNIT_PATH)) {
|
|
132
|
+
await run('systemctl', ['disable', exports.SERVICE_NAME]).catch(() => undefined);
|
|
133
|
+
(0, node_fs_1.rmSync)(UNIT_PATH, { force: true });
|
|
134
|
+
await run('systemctl', ['daemon-reload']).catch(() => undefined);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function status() {
|
|
138
|
+
if (hasSystemd() && (0, node_fs_1.existsSync)(UNIT_PATH)) {
|
|
139
|
+
const active = await run('systemctl', ['is-active', exports.SERVICE_NAME])
|
|
140
|
+
.then(({ stdout }) => stdout.trim())
|
|
141
|
+
.catch((error) => (error.stdout || 'inactive').trim());
|
|
142
|
+
return {
|
|
143
|
+
mode: 'systemd',
|
|
144
|
+
running: active === 'active',
|
|
145
|
+
pid: null,
|
|
146
|
+
detail: `systemd unit ${exports.SERVICE_NAME} is ${active}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const pid = readPid();
|
|
150
|
+
if (pid && alive(pid)) {
|
|
151
|
+
return { mode: 'process', running: true, pid, detail: `running as pid ${pid}` };
|
|
152
|
+
}
|
|
153
|
+
return { mode: pid ? 'process' : null, running: false, pid: null, detail: 'not running' };
|
|
154
|
+
}
|
|
155
|
+
/** Open the panel port in ufw, when ufw is installed and active. */
|
|
156
|
+
async function openFirewall(port) {
|
|
157
|
+
if (!isRoot())
|
|
158
|
+
return false;
|
|
159
|
+
try {
|
|
160
|
+
const { stdout } = await run('ufw', ['status']);
|
|
161
|
+
if (!stdout.toLowerCase().includes('status: active'))
|
|
162
|
+
return false;
|
|
163
|
+
await run('ufw', ['allow', `${port}/tcp`]);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Non-internal IPv4 addresses, used to tell the user where to point a browser. */
|
|
171
|
+
function addresses() {
|
|
172
|
+
const found = [];
|
|
173
|
+
for (const list of Object.values(node_os_1.default.networkInterfaces())) {
|
|
174
|
+
for (const net of list || []) {
|
|
175
|
+
if (net.family === 'IPv4' && !net.internal)
|
|
176
|
+
found.push(net.address);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return found;
|
|
180
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Remote, type StreamHandlers } from './ssh';
|
|
2
|
+
export type DatabaseEngine = 'postgres' | 'mysql' | 'mongodb';
|
|
3
|
+
export interface DatabaseConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
engine: DatabaseEngine;
|
|
6
|
+
user: string;
|
|
7
|
+
password: string;
|
|
8
|
+
port: number;
|
|
9
|
+
poolSize: number;
|
|
10
|
+
}
|
|
11
|
+
export interface DatabaseInstance {
|
|
12
|
+
name: string;
|
|
13
|
+
engine: DatabaseEngine;
|
|
14
|
+
user: string;
|
|
15
|
+
password: string;
|
|
16
|
+
port: number;
|
|
17
|
+
host: string;
|
|
18
|
+
pooledPort: number;
|
|
19
|
+
poolSize: number;
|
|
20
|
+
status: 'running' | 'stopped' | 'unknown';
|
|
21
|
+
}
|
|
22
|
+
/** Start the pooler sidecar for an engine that has one. Returns the host port, or 0. */
|
|
23
|
+
export declare function startPooler(remote: Remote, config: DatabaseConfig): Promise<number>;
|
|
24
|
+
/** List all databases managed by easy-vps. */
|
|
25
|
+
export declare function list(remote: Remote): Promise<DatabaseInstance[]>;
|
|
26
|
+
/** Create a new database container. */
|
|
27
|
+
export declare function createDatabase(remote: Remote, config: DatabaseConfig): Promise<{
|
|
28
|
+
port: number;
|
|
29
|
+
host: string;
|
|
30
|
+
pooledPort: number;
|
|
31
|
+
}>;
|
|
32
|
+
/** Remove a database container. */
|
|
33
|
+
export declare function removeDatabase(remote: Remote, name: string): Promise<void>;
|
|
34
|
+
/** Backup a database using pg_dump. */
|
|
35
|
+
export declare function backup(remote: Remote, name: string): Promise<{
|
|
36
|
+
path: string;
|
|
37
|
+
}>;
|
|
38
|
+
/** Restore a database from a dump file. */
|
|
39
|
+
export declare function restore(remote: Remote, name: string, dumpPath: string): Promise<void>;
|
|
40
|
+
/** Download a file from the VPS to the panel server using base64 encoding. */
|
|
41
|
+
export declare function downloadDump(remote: Remote, remotePath: string, localPath: string): Promise<void>;
|
|
42
|
+
/** Upload a database backup to S3. */
|
|
43
|
+
export declare function backupToS3(remote: Remote, name: string, engine: string): Promise<{
|
|
44
|
+
key: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** Stream a backup with progress logging via SSH execStream. */
|
|
47
|
+
export declare function backupStream(remote: Remote, name: string, engine: string, handlers: StreamHandlers): () => void;
|
|
48
|
+
/** Read a backup file from S3 and return its buffer. */
|
|
49
|
+
export declare function readBackupFromS3(name: string, engine: string, s3Key: string): Promise<{
|
|
50
|
+
buffer: Buffer;
|
|
51
|
+
fileName: string;
|
|
52
|
+
}>;
|
|
53
|
+
/** List backups for a database from S3. */
|
|
54
|
+
export declare function listBackupsFromS3(name: string, engine: string): Promise<{
|
|
55
|
+
key: string;
|
|
56
|
+
size: number;
|
|
57
|
+
lastModified: Date;
|
|
58
|
+
}[]>;
|
|
59
|
+
/** Restore a database from an S3 backup. */
|
|
60
|
+
export declare function restoreFromS3(remote: Remote, name: string, engine: string, s3Key: string): Promise<void>;
|