@stacksjs/ts-cloud 0.7.19 → 0.7.21
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/dist/bin/cli.js +536 -536
- package/dist/{chunk-nt2hrnva.js → chunk-eapb9et8.js} +155 -1
- package/dist/deploy/dashboard-site-settings.d.ts +55 -0
- package/dist/deploy/dashboard-site-settings.test.d.ts +1 -0
- package/dist/deploy/dashboard-throttle.d.ts +46 -0
- package/dist/deploy/dashboard-throttle.test.d.ts +1 -0
- package/dist/deploy/index.js +1 -1
- package/dist/index.js +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/ui/server/actions.html +3 -3
- package/dist/ui/server/backups.html +3 -3
- package/dist/ui/server/database.html +3 -3
- package/dist/ui/server/deployments.html +3 -3
- package/dist/ui/server/firewall.html +3 -3
- package/dist/ui/server/logs.html +3 -3
- package/dist/ui/server/services.html +3 -3
- package/dist/ui/server/sites.html +3 -3
- package/dist/ui/server/ssh-keys.html +3 -3
- package/dist/ui/server/team.html +3 -3
- package/dist/ui/server/terminal.html +3 -3
- package/dist/ui/server/workers.html +3 -3
- package/dist/ui/serverless/alarms.html +3 -3
- package/dist/ui/serverless/assets.html +3 -3
- package/dist/ui/serverless/data.html +3 -3
- package/dist/ui/serverless/deployments.html +3 -3
- package/dist/ui/serverless/functions.html +3 -3
- package/dist/ui/serverless/logs.html +3 -3
- package/dist/ui/serverless/queues.html +3 -3
- package/dist/ui/serverless/scheduler.html +3 -3
- package/dist/ui/serverless/secrets.html +3 -3
- package/dist/ui/serverless/traces.html +3 -3
- package/dist/ui/serverless.html +3 -3
- package/dist/ui-src/pages/server/deployments.stx +20 -5
- package/dist/ui-src/pages/server/logs.stx +26 -8
- package/package.json +3 -3
|
@@ -11803,6 +11803,7 @@ function scopeDashboardData(data, options) {
|
|
|
11803
11803
|
environment: data.environment,
|
|
11804
11804
|
environments: data.environments,
|
|
11805
11805
|
scoped: true,
|
|
11806
|
+
server: { name: "" },
|
|
11806
11807
|
sites,
|
|
11807
11808
|
sitesDetail,
|
|
11808
11809
|
workers: (data.workers ?? []).filter((w) => allowedSet.has(w.site)),
|
|
@@ -11833,6 +11834,135 @@ function scopeCloudConfig(config6, user) {
|
|
|
11833
11834
|
};
|
|
11834
11835
|
}
|
|
11835
11836
|
|
|
11837
|
+
// src/deploy/dashboard-site-settings.ts
|
|
11838
|
+
var MEMBER_EDITABLE_SITE_FIELDS = new Set([
|
|
11839
|
+
"name",
|
|
11840
|
+
"ssl",
|
|
11841
|
+
"env",
|
|
11842
|
+
"redirects",
|
|
11843
|
+
"aliases",
|
|
11844
|
+
"domain",
|
|
11845
|
+
"path"
|
|
11846
|
+
]);
|
|
11847
|
+
var ADMIN_ONLY_SITE_FIELDS = {
|
|
11848
|
+
build: "build runs as a shell command on the server",
|
|
11849
|
+
start: "start runs as a shell command on the server",
|
|
11850
|
+
root: "root is a filesystem path on the shared server",
|
|
11851
|
+
port: "ports are allocated by the server owner",
|
|
11852
|
+
type: "the deploy type is set by the server owner",
|
|
11853
|
+
php: "the PHP runtime is set by the server owner"
|
|
11854
|
+
};
|
|
11855
|
+
function checkMemberSiteFields(body) {
|
|
11856
|
+
const touched = Object.keys(body).filter((key) => body[key] !== undefined);
|
|
11857
|
+
const forbidden = touched.filter((key) => (key in ADMIN_ONLY_SITE_FIELDS));
|
|
11858
|
+
if (forbidden.length) {
|
|
11859
|
+
const reasons = forbidden.map((key) => `${key} (${ADMIN_ONLY_SITE_FIELDS[key]})`).join(", ");
|
|
11860
|
+
return { ok: false, error: `Only the server owner can change ${reasons}.` };
|
|
11861
|
+
}
|
|
11862
|
+
const unknown = touched.filter((key) => !MEMBER_EDITABLE_SITE_FIELDS.has(key));
|
|
11863
|
+
if (unknown.length)
|
|
11864
|
+
return { ok: false, error: `Only the server owner can change ${unknown.join(", ")}.` };
|
|
11865
|
+
return { ok: true };
|
|
11866
|
+
}
|
|
11867
|
+
function normalizeHost(value) {
|
|
11868
|
+
return String(value ?? "").trim().toLowerCase().replace(/\.$/, "");
|
|
11869
|
+
}
|
|
11870
|
+
function normalizePath(value) {
|
|
11871
|
+
const path = String(value ?? "/").trim();
|
|
11872
|
+
if (!path || path === "/")
|
|
11873
|
+
return "/";
|
|
11874
|
+
return (path.startsWith("/") ? path : `/${path}`).replace(/\/+$/, "") || "/";
|
|
11875
|
+
}
|
|
11876
|
+
function siteHosts(site) {
|
|
11877
|
+
const hosts = [normalizeHost(site?.domain)];
|
|
11878
|
+
if (Array.isArray(site?.aliases))
|
|
11879
|
+
hosts.push(...site.aliases.map(normalizeHost));
|
|
11880
|
+
return hosts.filter(Boolean);
|
|
11881
|
+
}
|
|
11882
|
+
function checkRouteConflict({ siteName, body, sites, ownSites }) {
|
|
11883
|
+
const current = sites[siteName] ?? {};
|
|
11884
|
+
const owned = new Set(ownSites);
|
|
11885
|
+
const wantedHosts = new Set;
|
|
11886
|
+
if (body.domain !== undefined)
|
|
11887
|
+
wantedHosts.add(normalizeHost(body.domain));
|
|
11888
|
+
if (Array.isArray(body.aliases))
|
|
11889
|
+
for (const alias of body.aliases)
|
|
11890
|
+
wantedHosts.add(normalizeHost(alias));
|
|
11891
|
+
wantedHosts.delete("");
|
|
11892
|
+
if (wantedHosts.size === 0)
|
|
11893
|
+
return { ok: true };
|
|
11894
|
+
const wantedPath = normalizePath(body.path !== undefined ? body.path : current.path);
|
|
11895
|
+
for (const [otherName, other] of Object.entries(sites)) {
|
|
11896
|
+
if (otherName === siteName || owned.has(otherName))
|
|
11897
|
+
continue;
|
|
11898
|
+
const otherPath = normalizePath(other?.path);
|
|
11899
|
+
for (const host of siteHosts(other)) {
|
|
11900
|
+
if (!wantedHosts.has(host))
|
|
11901
|
+
continue;
|
|
11902
|
+
if (otherPath === wantedPath) {
|
|
11903
|
+
return {
|
|
11904
|
+
ok: false,
|
|
11905
|
+
error: `${host}${wantedPath === "/" ? "" : wantedPath} is already served by another site on this server.`
|
|
11906
|
+
};
|
|
11907
|
+
}
|
|
11908
|
+
}
|
|
11909
|
+
}
|
|
11910
|
+
return { ok: true };
|
|
11911
|
+
}
|
|
11912
|
+
|
|
11913
|
+
// src/deploy/dashboard-throttle.ts
|
|
11914
|
+
var MAX_ATTEMPTS = 8;
|
|
11915
|
+
var LOCKOUT_MS = 15 * 60 * 1000;
|
|
11916
|
+
var WINDOW_MS = 15 * 60 * 1000;
|
|
11917
|
+
|
|
11918
|
+
class LoginThrottle {
|
|
11919
|
+
maxAttempts;
|
|
11920
|
+
lockoutMs;
|
|
11921
|
+
windowMs;
|
|
11922
|
+
attempts = new Map;
|
|
11923
|
+
constructor(maxAttempts = MAX_ATTEMPTS, lockoutMs = LOCKOUT_MS, windowMs = WINDOW_MS) {
|
|
11924
|
+
this.maxAttempts = maxAttempts;
|
|
11925
|
+
this.lockoutMs = lockoutMs;
|
|
11926
|
+
this.windowMs = windowMs;
|
|
11927
|
+
}
|
|
11928
|
+
key(username, address) {
|
|
11929
|
+
return `${username.trim().toLowerCase()}\x00${address}`;
|
|
11930
|
+
}
|
|
11931
|
+
check(username, address, now = Date.now()) {
|
|
11932
|
+
const entry = this.attempts.get(this.key(username, address));
|
|
11933
|
+
if (!entry?.until)
|
|
11934
|
+
return { allowed: true };
|
|
11935
|
+
if (now >= entry.until) {
|
|
11936
|
+
this.attempts.delete(this.key(username, address));
|
|
11937
|
+
return { allowed: true };
|
|
11938
|
+
}
|
|
11939
|
+
return { allowed: false, retryAfterSeconds: Math.ceil((entry.until - now) / 1000) };
|
|
11940
|
+
}
|
|
11941
|
+
recordFailure(username, address, now = Date.now()) {
|
|
11942
|
+
const key = this.key(username, address);
|
|
11943
|
+
const entry = this.attempts.get(key);
|
|
11944
|
+
const stale = !entry || now - entry.last > this.windowMs;
|
|
11945
|
+
const count = stale ? 1 : entry.count + 1;
|
|
11946
|
+
const next = { count, last: now };
|
|
11947
|
+
if (count >= this.maxAttempts)
|
|
11948
|
+
next.until = now + this.lockoutMs;
|
|
11949
|
+
this.attempts.set(key, next);
|
|
11950
|
+
}
|
|
11951
|
+
recordSuccess(username, address) {
|
|
11952
|
+
this.attempts.delete(this.key(username, address));
|
|
11953
|
+
}
|
|
11954
|
+
prune(now = Date.now()) {
|
|
11955
|
+
for (const [key, entry] of this.attempts) {
|
|
11956
|
+
const expired = entry.until ? now >= entry.until : now - entry.last > this.windowMs;
|
|
11957
|
+
if (expired)
|
|
11958
|
+
this.attempts.delete(key);
|
|
11959
|
+
}
|
|
11960
|
+
}
|
|
11961
|
+
get size() {
|
|
11962
|
+
return this.attempts.size;
|
|
11963
|
+
}
|
|
11964
|
+
}
|
|
11965
|
+
|
|
11836
11966
|
// src/deploy/firewall-config-editor.ts
|
|
11837
11967
|
var ALWAYS_OPEN = new Set([22, 80, 443]);
|
|
11838
11968
|
function isValidPort(port) {
|
|
@@ -12987,6 +13117,9 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12987
13117
|
const authEnabled = !authDisabled;
|
|
12988
13118
|
const secret = resolveSessionSecret(cwd);
|
|
12989
13119
|
const guard = createDashboardGuard({ cwd, enabled: authEnabled, secret });
|
|
13120
|
+
const throttle = new LoginThrottle;
|
|
13121
|
+
const throttleSweep = setInterval(() => throttle.prune(), 5 * 60 * 1000);
|
|
13122
|
+
throttleSweep.unref?.();
|
|
12990
13123
|
if (authEnabled) {
|
|
12991
13124
|
const bootstrap = ensureAdminUser(cwd, process.env.TS_CLOUD_UI_USERNAME?.trim() || "admin");
|
|
12992
13125
|
if (bootstrap.generated) {
|
|
@@ -13039,11 +13172,19 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
13039
13172
|
const body = await readJsonBody(req);
|
|
13040
13173
|
const username = String(body.username ?? "").trim();
|
|
13041
13174
|
const password = String(body.password ?? "");
|
|
13175
|
+
const address = server2.requestIP(req)?.address ?? "unknown";
|
|
13176
|
+
const gate = throttle.check(username, address);
|
|
13177
|
+
if (!gate.allowed) {
|
|
13178
|
+
return json({ ok: false, error: `Too many failed attempts. Try again in ${Math.ceil((gate.retryAfterSeconds ?? 0) / 60)} minute(s).` }, 429, { "retry-after": String(gate.retryAfterSeconds ?? 60) });
|
|
13179
|
+
}
|
|
13042
13180
|
const user2 = findUser(loadUsers(cwd), username);
|
|
13043
13181
|
const hash = user2?.passwordHash || DUMMY_HASH;
|
|
13044
13182
|
const ok = verifyPassword(password, hash) && !!user2;
|
|
13045
|
-
if (!ok)
|
|
13183
|
+
if (!ok) {
|
|
13184
|
+
throttle.recordFailure(username, address);
|
|
13046
13185
|
return json({ ok: false, error: "Incorrect username or password." }, 401);
|
|
13186
|
+
}
|
|
13187
|
+
throttle.recordSuccess(username, address);
|
|
13047
13188
|
const token = createSessionToken(user2.username, secret);
|
|
13048
13189
|
return json({ ok: true, user: describeUser(user2) }, 200, {
|
|
13049
13190
|
"set-cookie": serializeSessionCookie(token, { secure: cookieSecure })
|
|
@@ -13247,6 +13388,19 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
13247
13388
|
const existing = config6.sites?.[name];
|
|
13248
13389
|
if (!name || !existing)
|
|
13249
13390
|
return json({ ok: false, error: `Site '${name}' was not found.` }, 404);
|
|
13391
|
+
if (user.role === "member") {
|
|
13392
|
+
const fields = checkMemberSiteFields(body);
|
|
13393
|
+
if (!fields.ok)
|
|
13394
|
+
return json({ ok: false, error: fields.error }, 403);
|
|
13395
|
+
const conflict = checkRouteConflict({
|
|
13396
|
+
siteName: name,
|
|
13397
|
+
body,
|
|
13398
|
+
sites: config6.sites ?? {},
|
|
13399
|
+
ownSites: Object.keys(user.sites)
|
|
13400
|
+
});
|
|
13401
|
+
if (!conflict.ok)
|
|
13402
|
+
return json({ ok: false, error: conflict.error }, 409);
|
|
13403
|
+
}
|
|
13250
13404
|
if (body.port !== undefined && body.port !== null && body.port !== "" && (!Number.isInteger(Number(body.port)) || Number(body.port) < 1 || Number(body.port) > 65535))
|
|
13251
13405
|
return json({ ok: false, error: "Port must be a number between 1 and 65535." }, 422);
|
|
13252
13406
|
let text2 = await readFile(configPath, "utf8");
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which site settings a member may change, and route-conflict checks.
|
|
3
|
+
*
|
|
4
|
+
* `site:settings` lets a site owner edit their own site — but a site's config
|
|
5
|
+
* is not all equally harmless on a box shared with other tenants:
|
|
6
|
+
*
|
|
7
|
+
* - `build` and `start` are **shell commands run on the box at deploy time**,
|
|
8
|
+
* as root. A member who could set them would own the whole server, and every
|
|
9
|
+
* other tenant's site with it.
|
|
10
|
+
* - `root` is a filesystem path. Pointed at another site's directory (or `/`),
|
|
11
|
+
* it serves someone else's files to the internet.
|
|
12
|
+
* - `port`, `type` and `php` pick runtime and bind ports, which are the box's
|
|
13
|
+
* to allocate, not one tenant's.
|
|
14
|
+
*
|
|
15
|
+
* Those stay with the box owner. What is left is genuinely the tenant's own:
|
|
16
|
+
* TLS, their app's env, their redirects, and their routing — and routing is
|
|
17
|
+
* still checked against other tenants, since claiming `example.com` when
|
|
18
|
+
* someone else already serves it is hijacking their traffic.
|
|
19
|
+
*
|
|
20
|
+
* An allowlist, not a blocklist: a site field added later is admin-only until
|
|
21
|
+
* someone decides it is safe to hand over.
|
|
22
|
+
*/
|
|
23
|
+
/** Fields a member holding `site:settings` on the site may change. */
|
|
24
|
+
export declare const MEMBER_EDITABLE_SITE_FIELDS: ReadonlySet<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Fields only the box owner may change, with why — used to explain the refusal
|
|
27
|
+
* rather than a bare 403.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ADMIN_ONLY_SITE_FIELDS: Readonly<Record<string, string>>;
|
|
30
|
+
export interface FieldCheck {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a member may apply `body` to a site. Refuses the whole request if it
|
|
36
|
+
* touches any admin-only field, rather than silently applying the safe subset —
|
|
37
|
+
* a caller who asked to set `start` should be told it did not happen.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkMemberSiteFields(body: Record<string, any>): FieldCheck;
|
|
40
|
+
export interface RouteConflictInput {
|
|
41
|
+
/** The site being edited. */
|
|
42
|
+
siteName: string;
|
|
43
|
+
/** The requested changes (domain / path / aliases). */
|
|
44
|
+
body: Record<string, any>;
|
|
45
|
+
/** All sites on the box, by name. */
|
|
46
|
+
sites: Record<string, any>;
|
|
47
|
+
/** Site names the editor holds a grant on — conflicts with these are theirs. */
|
|
48
|
+
ownSites: string[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Refuse a routing change that would claim a host another tenant already
|
|
52
|
+
* serves. Conflicts with the editor's own sites are allowed: moving a domain
|
|
53
|
+
* between two sites you control is your business.
|
|
54
|
+
*/
|
|
55
|
+
export declare function checkRouteConflict({ siteName, body, sites, ownSites }: RouteConflictInput): FieldCheck;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login throttling.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard's login is internet-facing, and it is the door to a box that
|
|
5
|
+
* hosts other people's sites. scrypt makes each guess cost ~50ms, which slows
|
|
6
|
+
* an attacker but does not stop one — and while generated passwords are strong,
|
|
7
|
+
* an operator can set their own via `TS_CLOUD_UI_PASSWORD`.
|
|
8
|
+
*
|
|
9
|
+
* Failures are counted per key (username + client address) and lock that key
|
|
10
|
+
* out for a window once they pass a threshold. In-memory on purpose: the
|
|
11
|
+
* dashboard is a single process per box, and a restart clearing the counters is
|
|
12
|
+
* an acceptable trade for having no store to keep.
|
|
13
|
+
*
|
|
14
|
+
* Keyed on username *and* address so one attacker cannot lock a legitimate
|
|
15
|
+
* operator out of their own box by failing logins against their username
|
|
16
|
+
* (a denial of service dressed up as a security control).
|
|
17
|
+
*/
|
|
18
|
+
/** Failures before a key is locked out. */
|
|
19
|
+
export declare const MAX_ATTEMPTS: number;
|
|
20
|
+
/** How long a locked key stays locked. */
|
|
21
|
+
export declare const LOCKOUT_MS: number;
|
|
22
|
+
/** Failures older than this stop counting, so occasional typos never lock. */
|
|
23
|
+
export declare const WINDOW_MS: number;
|
|
24
|
+
export interface ThrottleDecision {
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
/** Seconds until the caller may try again. Only set when blocked. */
|
|
27
|
+
retryAfterSeconds?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class LoginThrottle {
|
|
30
|
+
private readonly maxAttempts;
|
|
31
|
+
private readonly lockoutMs;
|
|
32
|
+
private readonly windowMs;
|
|
33
|
+
private attempts;
|
|
34
|
+
constructor(maxAttempts?: number, lockoutMs?: number, windowMs?: number);
|
|
35
|
+
private key;
|
|
36
|
+
/** Whether a login attempt for this key may proceed. */
|
|
37
|
+
check(username: string, address: string, now?: number): ThrottleDecision;
|
|
38
|
+
/** Record a failed attempt, locking the key once it passes the threshold. */
|
|
39
|
+
recordFailure(username: string, address: string, now?: number): void;
|
|
40
|
+
/** Clear the counter for a key after a successful login. */
|
|
41
|
+
recordSuccess(username: string, address: string): void;
|
|
42
|
+
/** Drop expired entries so the map cannot grow without bound. */
|
|
43
|
+
prune(now?: number): void;
|
|
44
|
+
/** Tracked keys. For tests and diagnostics. */
|
|
45
|
+
get size(): number;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/deploy/index.js
CHANGED