anbaric-cloud-hosting 1.0.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/package.json +35 -0
- package/src/app-management/BaseBuildLayer.ts +178 -0
- package/src/app-management/BuildLayer.ts +19 -0
- package/src/app-management/DockerBuildLayer.ts +84 -0
- package/src/app-management/FargateBuildLayer.ts +232 -0
- package/src/auth/Authenticator.ts +18 -0
- package/src/auth/AuthenticatorLoader.ts +13 -0
- package/src/auth/CliAuthorizer.ts +54 -0
- package/src/auth/CliKey.ts +20 -0
- package/src/auth/CliKeyStore.ts +12 -0
- package/src/auth/InMemoryCliKeyStore.ts +27 -0
- package/src/auth/KeyPair.ts +13 -0
- package/src/auth/Role.ts +11 -0
- package/src/auth/Tenant.ts +11 -0
- package/src/auth/TokenAuthenticator.ts +62 -0
- package/src/auth/User.ts +22 -0
- package/src/data-store/PostgresCliKeyStore.ts +44 -0
- package/src/data-store/PostgresJobPersistence.ts +77 -0
- package/src/data-store/PostgresJsonStore.ts +41 -0
- package/src/data-store/Schema.ts +55 -0
- package/src/data-store/SecretsManagerSecretStore.ts +57 -0
- package/src/hosting/HostingServer.ts +130 -0
- package/src/hosting/Router.ts +306 -0
- package/src/hosting/pages/platform-ui.html +59 -0
- package/src/index.ts +24 -0
- package/src/main.ts +72 -0
- package/src/queuing/ConfirmableQueue.ts +9 -0
- package/src/queuing/ConsumerRegistry.ts +19 -0
- package/src/queuing/Dispatcher.ts +64 -0
- package/src/queuing/PostgresQueue.ts +54 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {CliKey} from "./CliKey";
|
|
2
|
+
import {CliKeyStore} from "./CliKeyStore";
|
|
3
|
+
|
|
4
|
+
class InMemoryCliKeyStore implements CliKeyStore {
|
|
5
|
+
|
|
6
|
+
private keys = new Map<string, CliKey>();
|
|
7
|
+
|
|
8
|
+
async save(key : CliKey) : Promise<void> {
|
|
9
|
+
this.keys.set(key.id, key);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async find(id : string) : Promise<CliKey | undefined> {
|
|
13
|
+
return this.keys.get(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async listFor(userId : string) : Promise<Array<CliKey>> {
|
|
17
|
+
return Array.from(this.keys.values()).filter(key => key.userId === userId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async delete(id : string, userId : string) : Promise<void> {
|
|
21
|
+
const key = this.keys.get(id);
|
|
22
|
+
if (key && key.userId === userId) this.keys.delete(id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { InMemoryCliKeyStore }
|
package/src/auth/Role.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {createPublicKey, verify} from "node:crypto";
|
|
2
|
+
import {IncomingMessage, ServerResponse} from "node:http";
|
|
3
|
+
import {CliKeyStore} from "./CliKeyStore";
|
|
4
|
+
import {User} from "./User";
|
|
5
|
+
|
|
6
|
+
const BEARER_PREFIX = "Bearer ";
|
|
7
|
+
|
|
8
|
+
class TokenAuthenticator {
|
|
9
|
+
|
|
10
|
+
constructor(private keyStore : CliKeyStore) {}
|
|
11
|
+
|
|
12
|
+
handles(request : IncomingMessage) : boolean {
|
|
13
|
+
return String(request.headers.authorization ?? "").startsWith(BEARER_PREFIX);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async authenticate(request : IncomingMessage, response : ServerResponse) : Promise<User | undefined> {
|
|
17
|
+
const token = String(request.headers.authorization).slice(BEARER_PREFIX.length);
|
|
18
|
+
const user = await this.verifiedUser(token);
|
|
19
|
+
if (!user) {
|
|
20
|
+
response.writeHead(401, { "content-type": "application/json" });
|
|
21
|
+
response.end(JSON.stringify({ error: "Invalid or expired token - run `anbaric login` to authorize this terminal" }));
|
|
22
|
+
}
|
|
23
|
+
return user;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private async verifiedUser(token : string) : Promise<User | undefined> {
|
|
27
|
+
const [headerPart, payloadPart, signaturePart] = token.split(".");
|
|
28
|
+
if (!headerPart || !payloadPart || !signaturePart) return undefined;
|
|
29
|
+
|
|
30
|
+
const header = this.decoded(headerPart);
|
|
31
|
+
const payload = this.decoded(payloadPart);
|
|
32
|
+
if (header?.alg !== "EdDSA" || typeof header?.kid !== "string") return undefined;
|
|
33
|
+
if (typeof payload?.exp !== "number" || payload.exp * 1000 < Date.now()) return undefined;
|
|
34
|
+
|
|
35
|
+
const key = await this.keyStore.find(header.kid);
|
|
36
|
+
if (!key) return undefined;
|
|
37
|
+
if (!this.signatureValid(headerPart, payloadPart, signaturePart, key.publicKey)) return undefined;
|
|
38
|
+
|
|
39
|
+
return new User(key.userId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private signatureValid(headerPart : string, payloadPart : string, signaturePart : string,
|
|
43
|
+
publicKeyPem : string) : boolean {
|
|
44
|
+
try {
|
|
45
|
+
return verify(null, Buffer.from(`${headerPart}.${payloadPart}`),
|
|
46
|
+
createPublicKey(publicKeyPem), Buffer.from(signaturePart, "base64url"));
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private decoded(part : string) : any {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { TokenAuthenticator }
|
package/src/auth/User.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {KeyPair} from "./KeyPair";
|
|
2
|
+
import {Role} from "./Role";
|
|
3
|
+
|
|
4
|
+
class User {
|
|
5
|
+
|
|
6
|
+
readonly id : string;
|
|
7
|
+
roles : Array<Role>;
|
|
8
|
+
keyPairs : Array<KeyPair>;
|
|
9
|
+
|
|
10
|
+
constructor(id : string, roles : Array<Role> = [], keyPairs : Array<KeyPair> = []) {
|
|
11
|
+
this.id = id;
|
|
12
|
+
this.roles = roles;
|
|
13
|
+
this.keyPairs = keyPairs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
hasRole(role : Role) : boolean {
|
|
17
|
+
return this.roles.some(existing => existing.id === role.id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { User }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
import {CliKey} from "../auth/CliKey";
|
|
3
|
+
import {CliKeyStore} from "../auth/CliKeyStore";
|
|
4
|
+
|
|
5
|
+
class PostgresCliKeyStore implements CliKeyStore {
|
|
6
|
+
|
|
7
|
+
constructor(private pool : Pool) {}
|
|
8
|
+
|
|
9
|
+
async save(key : CliKey) : Promise<void> {
|
|
10
|
+
await this.pool.query(
|
|
11
|
+
"INSERT INTO anbaric_system.cli_keys (id, user_id, client_name, public_key) VALUES ($1, $2, $3, $4)",
|
|
12
|
+
[key.id, key.userId, key.clientName, key.publicKey],
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async find(id : string) : Promise<CliKey | undefined> {
|
|
17
|
+
const result = await this.pool.query(
|
|
18
|
+
"SELECT id, user_id, client_name, public_key, created_at FROM anbaric_system.cli_keys WHERE id = $1",
|
|
19
|
+
[id],
|
|
20
|
+
);
|
|
21
|
+
const row = result.rows[0];
|
|
22
|
+
return row && new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.created_at);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async listFor(userId : string) : Promise<Array<CliKey>> {
|
|
26
|
+
const result = await this.pool.query(
|
|
27
|
+
"SELECT id, user_id, client_name, public_key, created_at FROM anbaric_system.cli_keys WHERE user_id = $1 ORDER BY created_at",
|
|
28
|
+
[userId],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return result.rows.map(row =>
|
|
32
|
+
new CliKey(row.id, row.user_id, row.client_name, row.public_key, row.created_at));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async delete(id : string, userId : string) : Promise<void> {
|
|
36
|
+
await this.pool.query(
|
|
37
|
+
"DELETE FROM anbaric_system.cli_keys WHERE id = $1 AND user_id = $2",
|
|
38
|
+
[id, userId],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { PostgresCliKeyStore }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {Job, JobPersistence, JobTransition, deserializeJob} from "anbaric-tsapi";
|
|
2
|
+
import {Pool} from "pg";
|
|
3
|
+
|
|
4
|
+
type JobRow = {
|
|
5
|
+
id : string,
|
|
6
|
+
state : string,
|
|
7
|
+
properties : Record<string, any>,
|
|
8
|
+
workflow_id? : string,
|
|
9
|
+
started_at : Date,
|
|
10
|
+
started_by : string,
|
|
11
|
+
last_updated : Date,
|
|
12
|
+
transitions : Array<JobTransition>,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const JOB_COLUMNS = "id, state, properties, workflow_id, started_at, started_by, last_updated, transitions";
|
|
16
|
+
|
|
17
|
+
class PostgresJobPersistence implements JobPersistence {
|
|
18
|
+
|
|
19
|
+
constructor(private pool : Pool) {}
|
|
20
|
+
|
|
21
|
+
async save(job : Job) : Promise<void> {
|
|
22
|
+
await this.pool.query(
|
|
23
|
+
`INSERT INTO jobs (id, state, properties, workflow_id, started_at, started_by, last_updated, transitions)
|
|
24
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
25
|
+
ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state, properties = EXCLUDED.properties,
|
|
26
|
+
workflow_id = EXCLUDED.workflow_id, last_updated = EXCLUDED.last_updated,
|
|
27
|
+
transitions = EXCLUDED.transitions`,
|
|
28
|
+
[job.id, job.stateId, Object.fromEntries(job.properties), job.workflowId,
|
|
29
|
+
job.startedAt, job.startedBy, job.lastUpdated, JSON.stringify(job.transitions)],
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async retrieve(id : string) : Promise<Job> {
|
|
34
|
+
const result = await this.pool.query(
|
|
35
|
+
`SELECT ${JOB_COLUMNS} FROM jobs WHERE id = $1`,
|
|
36
|
+
[id],
|
|
37
|
+
);
|
|
38
|
+
if (result.rowCount === 0) throw new Error(`No job found with id "${id}"`);
|
|
39
|
+
|
|
40
|
+
return this.deserializeRow(result.rows[0]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async delete(id : string) : Promise<void> {
|
|
44
|
+
await this.pool.query("DELETE FROM jobs WHERE id = $1", [id]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async list(pageSize : number = 100, page : number = 0) : Promise<Array<Job>> {
|
|
48
|
+
const result = await this.pool.query(
|
|
49
|
+
`SELECT ${JOB_COLUMNS} FROM jobs ORDER BY inserted_at LIMIT $1 OFFSET $2`,
|
|
50
|
+
[pageSize, page * pageSize],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return result.rows.map(row => this.deserializeRow(row));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private deserializeRow(row : JobRow) : Job {
|
|
57
|
+
return deserializeJob({
|
|
58
|
+
...row,
|
|
59
|
+
workflowId: row.workflow_id ?? undefined,
|
|
60
|
+
startedAt: row.started_at.toISOString(),
|
|
61
|
+
startedBy: row.started_by,
|
|
62
|
+
lastUpdated: row.last_updated.toISOString(),
|
|
63
|
+
transitions: row.transitions,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async updateProperties(id : string, properties : Map<string, any>) : Promise<void> {
|
|
68
|
+
const result = await this.pool.query(
|
|
69
|
+
"UPDATE jobs SET properties = properties || $2::jsonb, last_updated = now() WHERE id = $1",
|
|
70
|
+
[id, Object.fromEntries(properties)],
|
|
71
|
+
);
|
|
72
|
+
if (result.rowCount === 0) throw new Error(`No job found with id "${id}"`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { PostgresJobPersistence }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {JsonStore} from "anbaric-tsapi";
|
|
2
|
+
import {Pool} from "pg";
|
|
3
|
+
|
|
4
|
+
class PostgresJsonStore implements JsonStore {
|
|
5
|
+
|
|
6
|
+
constructor(private pool : Pool, private collection : string) {}
|
|
7
|
+
|
|
8
|
+
async save(id : string, document : any) : Promise<void> {
|
|
9
|
+
await this.pool.query(
|
|
10
|
+
`INSERT INTO documents (collection, id, document) VALUES ($1, $2, $3)
|
|
11
|
+
ON CONFLICT (collection, id) DO UPDATE SET document = EXCLUDED.document`,
|
|
12
|
+
[this.collection, id, document],
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async retrieve(id : string) : Promise<any> {
|
|
17
|
+
const result = await this.pool.query(
|
|
18
|
+
"SELECT document FROM documents WHERE collection = $1 AND id = $2",
|
|
19
|
+
[this.collection, id],
|
|
20
|
+
);
|
|
21
|
+
if (result.rowCount === 0) throw new Error(`No document found with id "${id}"`);
|
|
22
|
+
|
|
23
|
+
return result.rows[0].document;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async delete(id : string) : Promise<void> {
|
|
27
|
+
await this.pool.query("DELETE FROM documents WHERE collection = $1 AND id = $2", [this.collection, id]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async list(pageSize : number = 100, page : number = 0) : Promise<Array<any>> {
|
|
31
|
+
const result = await this.pool.query(
|
|
32
|
+
"SELECT document FROM documents WHERE collection = $1 ORDER BY inserted_at LIMIT $2 OFFSET $3",
|
|
33
|
+
[this.collection, pageSize, page * pageSize],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return result.rows.map(row => row.document);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { PostgresJsonStore }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
|
|
3
|
+
/* Workflow data (jobs, queue, documents) lives in the default public schema;
|
|
4
|
+
platform system tables are encapsulated in the anbaric_system schema so the
|
|
5
|
+
two never mix. */
|
|
6
|
+
const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
7
|
+
await pool.query(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
state TEXT NOT NULL,
|
|
11
|
+
properties JSONB NOT NULL DEFAULT '{}',
|
|
12
|
+
workflow_id TEXT,
|
|
13
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
14
|
+
started_by TEXT NOT NULL DEFAULT 'system',
|
|
15
|
+
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
16
|
+
transitions JSONB NOT NULL DEFAULT '[]',
|
|
17
|
+
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS workflow_id TEXT");
|
|
21
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT now()");
|
|
22
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_by TEXT NOT NULL DEFAULT 'system'");
|
|
23
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_updated TIMESTAMPTZ NOT NULL DEFAULT now()");
|
|
24
|
+
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'");
|
|
25
|
+
await pool.query(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
27
|
+
collection TEXT NOT NULL,
|
|
28
|
+
id TEXT NOT NULL,
|
|
29
|
+
document JSONB NOT NULL,
|
|
30
|
+
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY,
|
|
31
|
+
PRIMARY KEY (collection, id)
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
await pool.query(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS queue (
|
|
36
|
+
position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
37
|
+
job_id TEXT NOT NULL,
|
|
38
|
+
workflow_id TEXT NOT NULL,
|
|
39
|
+
due TIMESTAMPTZ,
|
|
40
|
+
leased_until TIMESTAMPTZ
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
|
|
44
|
+
await pool.query(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
user_id TEXT NOT NULL,
|
|
48
|
+
client_name TEXT NOT NULL,
|
|
49
|
+
public_key TEXT NOT NULL,
|
|
50
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export { ensureSchema }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateSecretCommand,
|
|
3
|
+
DeleteSecretCommand,
|
|
4
|
+
GetSecretValueCommand,
|
|
5
|
+
ListSecretsCommand,
|
|
6
|
+
PutSecretValueCommand,
|
|
7
|
+
ResourceExistsException,
|
|
8
|
+
ResourceNotFoundException,
|
|
9
|
+
SecretsManagerClient,
|
|
10
|
+
} from "@aws-sdk/client-secrets-manager";
|
|
11
|
+
import {SecretStore} from "anbaric-tsapi";
|
|
12
|
+
|
|
13
|
+
class SecretsManagerSecretStore implements SecretStore {
|
|
14
|
+
|
|
15
|
+
constructor(private client : SecretsManagerClient, private prefix : string = "anbaric/") {}
|
|
16
|
+
|
|
17
|
+
async save(name : string, value : string) : Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await this.client.send(new CreateSecretCommand({ Name: this.prefix + name, SecretString: value }));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!(error instanceof ResourceExistsException)) throw error;
|
|
22
|
+
await this.client.send(new PutSecretValueCommand({ SecretId: this.prefix + name, SecretString: value }));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async retrieve(name : string) : Promise<string> {
|
|
27
|
+
try {
|
|
28
|
+
const secret = await this.client.send(new GetSecretValueCommand({ SecretId: this.prefix + name }));
|
|
29
|
+
return secret.SecretString ?? "";
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error instanceof ResourceNotFoundException) throw new Error(`No secret found with name "${name}"`);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async delete(name : string) : Promise<void> {
|
|
37
|
+
try {
|
|
38
|
+
await this.client.send(new DeleteSecretCommand({ SecretId: this.prefix + name, ForceDeleteWithoutRecovery: true }));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!(error instanceof ResourceNotFoundException)) throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async list() : Promise<Array<string>> {
|
|
45
|
+
const result = await this.client.send(new ListSecretsCommand({
|
|
46
|
+
Filters: [{ Key: "name", Values: [this.prefix] }],
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
return (result.SecretList ?? [])
|
|
50
|
+
.map(secret => secret.Name ?? "")
|
|
51
|
+
.filter(name => name.startsWith(this.prefix))
|
|
52
|
+
.map(name => name.slice(this.prefix.length));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { SecretsManagerSecretStore }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {createServer, IncomingMessage, Server, ServerResponse} from "node:http";
|
|
2
|
+
import {AddressInfo} from "node:net";
|
|
3
|
+
import {JobPersistence, JsonStore, SecretStore} from "anbaric-tsapi";
|
|
4
|
+
import {BuildLayer} from "../app-management/BuildLayer";
|
|
5
|
+
import {Authenticator, SESSION_COOKIE} from "../auth/Authenticator";
|
|
6
|
+
import {CliAuthorizer} from "../auth/CliAuthorizer";
|
|
7
|
+
import {Tenant} from "../auth/Tenant";
|
|
8
|
+
import {TokenAuthenticator} from "../auth/TokenAuthenticator";
|
|
9
|
+
import {User} from "../auth/User";
|
|
10
|
+
import {ConfirmableQueue} from "../queuing/ConfirmableQueue";
|
|
11
|
+
import {ConsumerRegistry} from "../queuing/ConsumerRegistry";
|
|
12
|
+
import {Router} from "./Router";
|
|
13
|
+
|
|
14
|
+
const INTERNAL_RESOURCES = new Set(["jobs", "queue", "consumers", "state-machines", "documents", "secrets"]);
|
|
15
|
+
|
|
16
|
+
class HostingServer {
|
|
17
|
+
|
|
18
|
+
private server : Server;
|
|
19
|
+
private internalServer : Server;
|
|
20
|
+
private router : Router;
|
|
21
|
+
|
|
22
|
+
constructor(persistence : JobPersistence, queue : ConfirmableQueue,
|
|
23
|
+
registry : ConsumerRegistry = new ConsumerRegistry(),
|
|
24
|
+
buildLayer? : BuildLayer,
|
|
25
|
+
documentStoreFor? : (collection : string) => JsonStore,
|
|
26
|
+
secretStore? : SecretStore,
|
|
27
|
+
private authenticator? : Authenticator,
|
|
28
|
+
cliAuthorizer? : CliAuthorizer,
|
|
29
|
+
private tokenAuthenticator? : TokenAuthenticator,
|
|
30
|
+
tenant? : string) {
|
|
31
|
+
this.router = new Router(persistence, queue, registry, buildLayer, documentStoreFor, secretStore, cliAuthorizer, tenant);
|
|
32
|
+
this.server = this.serverFor((request, response) => this.handle(request, response));
|
|
33
|
+
this.internalServer = this.serverFor((request, response) => this.handleInternal(request, response));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
listen(port : number) : Promise<number> {
|
|
37
|
+
return new Promise(resolve =>
|
|
38
|
+
this.server.listen(port, () => resolve((this.server.address() as AddressInfo).port)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
listenInternal(port : number) : Promise<number> {
|
|
42
|
+
return new Promise(resolve =>
|
|
43
|
+
this.internalServer.listen(port, () => resolve((this.internalServer.address() as AddressInfo).port)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
close() : Promise<void> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (this.internalServer.listening) this.internalServer.close();
|
|
49
|
+
this.server.close(error => error ? reject(error) : resolve());
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private serverFor(handle : (request : IncomingMessage, response : ServerResponse) => Promise<void>) : Server {
|
|
54
|
+
return createServer((request, response) => {
|
|
55
|
+
handle(request, response).catch(error => {
|
|
56
|
+
const message = error instanceof Error ? error.message : "Internal error";
|
|
57
|
+
const status = /^No .+ found/.test(message) ? 404 : 500;
|
|
58
|
+
this.reply(response, status, { error: message });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async handle(request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
64
|
+
const path = request.url?.split("?")[0] ?? "/";
|
|
65
|
+
if (path === "/ping" && request.method === "GET") {
|
|
66
|
+
return this.reply(response, 200, { status: "ok" });
|
|
67
|
+
}
|
|
68
|
+
if (/^\/authorize-cli\/[^/]+\/poll$/.test(path) && request.method === "GET") {
|
|
69
|
+
return this.router.route(request, response);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.tokenAuthenticator?.handles(request)) {
|
|
73
|
+
const user = await this.tokenAuthenticator.authenticate(request, response);
|
|
74
|
+
if (!user) return;
|
|
75
|
+
return this.authorizeAndRoute(user, undefined, request, response);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const authenticated = await this.authenticateSession(request, response);
|
|
79
|
+
if (this.authenticator && !authenticated) return;
|
|
80
|
+
if (authenticated) return this.authorizeAndRoute(authenticated[0], authenticated[1], request, response);
|
|
81
|
+
|
|
82
|
+
await this.router.route(request, response);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async handleInternal(request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
86
|
+
const path = request.url?.split("?")[0] ?? "/";
|
|
87
|
+
if (path === "/ping" && request.method === "GET") {
|
|
88
|
+
return this.reply(response, 200, { status: "ok" });
|
|
89
|
+
}
|
|
90
|
+
const [resource] = path.split("/").filter(Boolean);
|
|
91
|
+
if (!resource || !INTERNAL_RESOURCES.has(resource)) {
|
|
92
|
+
return this.reply(response, 404, { error: "Not found" });
|
|
93
|
+
}
|
|
94
|
+
await this.router.route(request, response);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async authorizeAndRoute(user : User, tenant : Tenant | undefined,
|
|
98
|
+
request : IncomingMessage, response : ServerResponse) : Promise<void> {
|
|
99
|
+
if (this.authenticator) {
|
|
100
|
+
const permitted = await this.authenticator.authorize(user, request, response);
|
|
101
|
+
if (!permitted) {
|
|
102
|
+
if (!response.writableEnded) this.reply(response, 403, { error: "Not authorized" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await this.router.route(request, response, user, tenant);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async authenticateSession(request : IncomingMessage, response : ServerResponse) : Promise<[User, Tenant] | undefined> {
|
|
110
|
+
if (!this.authenticator) return undefined;
|
|
111
|
+
return this.authenticator.authenticate(this.sessionCookie(request), request, response);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private sessionCookie(request : IncomingMessage) : string | undefined {
|
|
115
|
+
const cookies = String(request.headers.cookie ?? "").split(";");
|
|
116
|
+
for (const cookie of cookies) {
|
|
117
|
+
const [name, ...value] = cookie.trim().split("=");
|
|
118
|
+
if (name === SESSION_COOKIE) return value.join("=");
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private reply(response : ServerResponse, status : number, body : unknown) : void {
|
|
124
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
125
|
+
response.end(JSON.stringify(body));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { HostingServer }
|