+6
proxy/bun.lock
+6
proxy/bun.lock
···
11
11
"@atcute/lexicons": "^1.1.0",
12
12
"@atcute/tid": "^1.0.2",
13
13
"barometer-lexicon": "file:../lib",
14
+
"parsimmon": "^1.18.1",
14
15
},
15
16
"devDependencies": {
16
17
"@types/bun": "latest",
18
+
"@types/parsimmon": "^1.10.9",
17
19
"concurrently": "^9.2.0",
18
20
},
19
21
"peerDependencies": {
···
48
50
49
51
"@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
50
52
53
+
"@types/parsimmon": ["@types/parsimmon@1.10.9", "", {}, "sha512-O2M2x1w+m7gWLen8i5DOy6tWRnbRcsW6Pke3j3HAsJUrPb4g0MgjksIUm2aqUtCYxy7Qjr3CzjjwQBzhiGn46A=="],
54
+
51
55
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
52
56
53
57
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
···
83
87
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
84
88
85
89
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
90
+
91
+
"parsimmon": ["parsimmon@1.18.1", "", {}, "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw=="],
86
92
87
93
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
88
94
+4
-1
proxy/gen-routes.ts
+4
-1
proxy/gen-routes.ts
+3
-1
proxy/package.json
+3
-1
proxy/package.json
···
9
9
},
10
10
"devDependencies": {
11
11
"@types/bun": "latest",
12
+
"@types/parsimmon": "^1.10.9",
12
13
"concurrently": "^9.2.0"
13
14
},
14
15
"peerDependencies": {
···
21
22
"@atcute/identity-resolver": "^1.1.3",
22
23
"@atcute/lexicons": "^1.1.0",
23
24
"@atcute/tid": "^1.0.2",
24
-
"barometer-lexicon": "file:../lib"
25
+
"barometer-lexicon": "file:../lib",
26
+
"parsimmon": "^1.18.1"
25
27
}
26
28
}
+21
-7
proxy/src/index.ts
+21
-7
proxy/src/index.ts
···
1
1
import os from "os";
2
-
3
2
import { Client, CredentialManager } from "@atcute/client";
4
3
import { getPdsEndpoint } from "@atcute/identity";
5
4
import {
···
9
8
} from "@atcute/identity-resolver";
10
9
import { config } from "./config";
11
10
import type {} from "@atcute/atproto";
12
-
import { getRecord, ok, putRecord, type Result } from "./utils";
11
+
import {
12
+
applyMiddleware,
13
+
applyMiddlewareAll,
14
+
getRecord,
15
+
log,
16
+
ok,
17
+
putRecord,
18
+
type Middleware,
19
+
type Result,
20
+
} from "./utils";
13
21
import store from "./store";
14
22
import routes from "./routes";
15
23
···
36
44
// fetch host record for this host
37
45
const maybeRecord = await getRecord(
38
46
"systems.gaze.barometer.host",
39
-
os.hostname(),
47
+
store.hostname,
40
48
);
41
49
if (maybeRecord.ok) {
42
50
store.host = maybeRecord.value;
···
44
52
45
53
// if it doesnt exist we make a new one
46
54
if (store.host === null) {
47
-
const hostname = os.hostname();
48
55
await putRecord(
49
56
{
50
57
$type: "systems.gaze.barometer.host",
51
-
name: config.hostName ?? hostname,
58
+
name: config.hostName ?? store.hostname,
52
59
description: config.hostDescription,
53
60
os: os.platform(),
54
61
},
55
-
hostname,
62
+
store.hostname,
56
63
);
57
64
}
58
65
59
-
const server = Bun.serve({ routes });
66
+
const traceRequest: Middleware = async (req) => {
67
+
const url = new URL(req.url);
68
+
log.info(`${req.method} ${url.pathname}`);
69
+
return req;
70
+
};
71
+
const server = Bun.serve({
72
+
routes: applyMiddlewareAll([traceRequest], routes),
73
+
});
60
74
61
75
console.log(`server running on http://localhost:${server.port}`);
+4
-1
proxy/src/routes/index.ts
+4
-1
proxy/src/routes/index.ts
+77
-18
proxy/src/routes/push.ts
+77
-18
proxy/src/routes/push.ts
···
1
-
import { SystemsGazeBarometerState } from "barometer-lexicon";
2
1
import { err, expect, getRecord, ok, putRecord, type Result } from "../utils";
3
2
import { parseCanonicalResourceUri, safeParse } from "@atcute/lexicons";
4
-
import store from "../store";
3
+
import store, { type Service } from "../store";
4
+
import { systemctlShow } from "../systemd";
5
+
import { config } from "../config";
6
+
import { now as generateTid } from "@atcute/tid";
7
+
import * as v from "@atcute/lexicons/validations";
8
+
import type { SystemsGazeBarometerService } from "barometer-lexicon";
9
+
10
+
// this is hacky but we want to make forService be optional so its okay
11
+
const StateSchemaSubset = v.record(
12
+
v.tidString(),
13
+
v.object({
14
+
$type: v.literal("systems.gaze.barometer.state"),
15
+
changedAt: v.datetimeString(),
16
+
forService: v.optional(v.resourceUriString()),
17
+
generatedBy: v.optional(v.resourceUriString()),
18
+
reason: v.optional(v.string()),
19
+
from: v.literalEnum([
20
+
"systems.gaze.barometer.status.degraded",
21
+
"systems.gaze.barometer.status.healthy",
22
+
"systems.gaze.barometer.status.unknown",
23
+
]),
24
+
to: v.literalEnum([
25
+
"systems.gaze.barometer.status.degraded",
26
+
"systems.gaze.barometer.status.healthy",
27
+
]),
28
+
}),
29
+
);
5
30
6
31
interface PushRequest {
7
32
serviceName?: string; // service manager service name
8
-
state: SystemsGazeBarometerState.Main;
33
+
state: v.InferOutput<typeof StateSchemaSubset>;
9
34
}
10
35
11
36
const parsePushRequest = (json: unknown): Result<PushRequest, string> => {
···
16
41
return err("serviceName is not a string");
17
42
}
18
43
if ("state" in json) {
19
-
const parsed = safeParse(SystemsGazeBarometerState.mainSchema, json.state);
44
+
const parsed = safeParse(StateSchemaSubset, json.state);
20
45
if (!parsed.ok) {
21
46
return err(`state is invalid: ${parsed.message}`);
22
47
}
···
38
63
}
39
64
const data = maybeData.value;
40
65
41
-
const serviceAtUri = expect(parseCanonicalResourceUri(data.state.forService));
42
-
let service = store.services.get(serviceAtUri.rkey);
43
-
if (!service) {
44
-
const serviceRecord = await getRecord(
45
-
"systems.gaze.barometer.service",
46
-
serviceAtUri.rkey,
66
+
let service: Service | undefined = undefined;
67
+
if (data.state.forService) {
68
+
const serviceAtUri = expect(
69
+
parseCanonicalResourceUri(data.state.forService),
47
70
);
48
-
if (!serviceRecord.ok) {
71
+
service = store.services.get(serviceAtUri.rkey);
72
+
if (!service) {
73
+
let serviceRecord = await getRecord(
74
+
"systems.gaze.barometer.service",
75
+
serviceAtUri.rkey,
76
+
);
77
+
if (!serviceRecord.ok) {
78
+
return badRequest({
79
+
msg: `service was not found or is invalid: ${serviceRecord.error}`,
80
+
});
81
+
}
82
+
service = {
83
+
record: serviceRecord.value,
84
+
checks: new Map(),
85
+
};
86
+
store.services.set(serviceAtUri.rkey, service);
87
+
}
88
+
} else if (data.serviceName) {
89
+
const serviceInfo = await systemctlShow(data.serviceName);
90
+
if (serviceInfo.ok) {
91
+
const record: SystemsGazeBarometerService.Main = {
92
+
$type: "systems.gaze.barometer.service",
93
+
name: data.serviceName,
94
+
description: serviceInfo.value.description,
95
+
hostedBy: `at://${config.repoDid}/systems.gaze.barometer.host/${store.hostname}`,
96
+
};
97
+
const rkey = generateTid();
98
+
const putAt = await putRecord(record, rkey);
99
+
data.state.forService = putAt.uri;
100
+
service = {
101
+
record,
102
+
checks: new Map(),
103
+
};
104
+
store.services.set(rkey, service);
105
+
} else {
49
106
return badRequest({
50
-
msg: `service was not found or is invalid: ${serviceRecord.error}`,
107
+
msg: `could not fetch service from systemd: ${serviceInfo.error}`,
51
108
});
52
109
}
53
-
service = {
54
-
record: serviceRecord.value,
55
-
checks: new Map(),
56
-
};
57
-
store.services.set(serviceAtUri.rkey, service);
110
+
} else {
111
+
return badRequest({
112
+
msg: `either 'state.forService' or 'serviceName' must be provided`,
113
+
});
58
114
}
59
115
60
116
if (data.state.generatedBy) {
···
79
135
}
80
136
}
81
137
82
-
const result = await putRecord(data.state);
138
+
const result = await putRecord(
139
+
{ ...data.state, forService: data.state.forService! },
140
+
generateTid(),
141
+
);
83
142
return new Response(JSON.stringify({ cid: result.cid, uri: result.uri }));
84
143
};
+5
-2
proxy/src/store.ts
+5
-2
proxy/src/store.ts
···
1
+
import os from "os";
1
2
import type { RecordKey } from "@atcute/lexicons";
2
3
import type {
3
4
SystemsGazeBarometerCheck,
···
5
6
SystemsGazeBarometerService,
6
7
} from "barometer-lexicon";
7
8
8
-
interface Check {
9
+
export interface Check {
9
10
record: SystemsGazeBarometerCheck.Main;
10
11
}
11
-
interface Service {
12
+
export interface Service {
12
13
checks: Map<RecordKey, Check>;
13
14
record: SystemsGazeBarometerService.Main;
14
15
}
···
16
17
class Store {
17
18
services;
18
19
host: SystemsGazeBarometerHost.Main | null;
20
+
hostname: string;
19
21
20
22
constructor() {
21
23
this.services = new Map<RecordKey, Service>();
22
24
this.host = null;
25
+
this.hostname = os.hostname();
23
26
}
24
27
}
25
28
+92
proxy/src/systemd.ts
+92
proxy/src/systemd.ts
···
1
+
import { spawn } from "bun";
2
+
import P from "parsimmon";
3
+
import { err, ok, type Result } from "./utils";
4
+
5
+
interface SystemctlShowOutput {
6
+
[key: string]: string;
7
+
}
8
+
9
+
// Parsimmon parsers for systemctl output
10
+
const newline = P.string("\n");
11
+
const equals = P.string("=");
12
+
13
+
// Key: anything except = and newline
14
+
const key = P.regexp(/[^=\n]+/).map((s) => s.trim());
15
+
16
+
// Single line value: everything until newline (or end of input)
17
+
const singleLineValue = P.regexp(/[^\n]*/);
18
+
19
+
// Continuation line: newline followed by whitespace and content
20
+
const continuationLine = P.seq(
21
+
newline,
22
+
P.regexp(/[ \t]*/), // optional whitespace
23
+
P.regexp(/[^\n]*/), // content
24
+
).map(([, , content]) => "\n" + content);
25
+
26
+
// Multi-line value: first line + any continuation lines
27
+
const multiLineValue = P.seq(singleLineValue, continuationLine.many()).map(
28
+
([first, continuations]) => (first + continuations.join("")).trim(),
29
+
);
30
+
31
+
// Key-value pair: key = value
32
+
const keyValuePair = P.seq(key, equals, multiLineValue).map(([k, , v]) => ({
33
+
key: k,
34
+
value: v,
35
+
}));
36
+
37
+
// Empty line (just whitespace)
38
+
const emptyLine = P.regexp(/[ \t]*/).result(null);
39
+
40
+
// A line is either a key-value pair or empty line
41
+
const line = P.alt(keyValuePair, emptyLine);
42
+
43
+
// Complete systemctl output: lines separated by newlines, ending with optional newline
44
+
const systemctlOutput = P.seq(line.sepBy(newline), P.alt(newline, P.eof)).map(
45
+
([lines]) =>
46
+
lines.filter((l): l is { key: string; value: string } => l !== null),
47
+
);
48
+
49
+
const parseSystemctlOutput = (
50
+
output: string,
51
+
): Result<SystemctlShowOutput, string> => {
52
+
const result = systemctlOutput.parse(output);
53
+
54
+
if (!result.status) {
55
+
return err(
56
+
`Parse error at position ${result.index.offset}: ${result.expected.join(", ")}`,
57
+
);
58
+
}
59
+
60
+
const kvMap: SystemctlShowOutput = {};
61
+
62
+
for (const { key, value } of result.value) {
63
+
if (value.length > 0) {
64
+
kvMap[key.toLowerCase()] = value;
65
+
}
66
+
}
67
+
68
+
return ok(kvMap);
69
+
};
70
+
71
+
export const systemctlShow = async (
72
+
serviceName: string,
73
+
): Promise<Result<SystemctlShowOutput, string>> => {
74
+
try {
75
+
const proc = spawn(["systemctl", "show", `${serviceName}.service`], {
76
+
stdout: "pipe",
77
+
stderr: "pipe",
78
+
});
79
+
80
+
const output = await new Response(proc.stdout).text();
81
+
const exitCode = await proc.exited;
82
+
83
+
if (exitCode !== 0) {
84
+
const error = await new Response(proc.stderr).text();
85
+
return err(`systemctl show failed with exit code ${exitCode}: ${error}`);
86
+
}
87
+
88
+
return parseSystemctlOutput(output);
89
+
} catch (error) {
90
+
return err(`failed to execute systemctl show: ${error}`);
91
+
}
92
+
};
+51
-3
proxy/src/utils.ts
+51
-3
proxy/src/utils.ts
···
2
2
import { schemas as BarometerSchemas } from "barometer-lexicon";
3
3
import { config } from "./config";
4
4
import { ok as clientOk } from "@atcute/client";
5
-
import { now as generateTid } from "@atcute/tid";
6
5
import { atpClient } from ".";
7
6
8
7
export type Result<T, E> =
···
64
63
Collection extends keyof typeof BarometerSchemas,
65
64
>(
66
65
record: InferOutput<(typeof BarometerSchemas)[Collection]>,
67
-
rkey?: RecordKey,
66
+
rkey: RecordKey,
68
67
) => {
69
68
return await clientOk(
70
69
atpClient.post("com.atproto.repo.putRecord", {
···
72
71
collection: record["$type"],
73
72
repo: config.repoDid,
74
73
record,
75
-
rkey: rkey ?? generateTid(),
74
+
rkey,
76
75
},
77
76
}),
78
77
);
79
78
};
79
+
80
+
export const log = {
81
+
info: console.log,
82
+
warn: console.warn,
83
+
error: console.error,
84
+
};
85
+
86
+
export type Middleware = (
87
+
req: Bun.BunRequest,
88
+
) => Promise<Bun.BunRequest | Response>;
89
+
90
+
export const applyMiddleware =
91
+
<T extends string>(
92
+
fns: Middleware[],
93
+
route: Bun.RouterTypes.RouteHandler<T>,
94
+
): Bun.RouterTypes.RouteHandler<T> =>
95
+
async (req, srv) => {
96
+
for (const fn of fns) {
97
+
const result = await fn(req);
98
+
if (result instanceof Response) {
99
+
return result;
100
+
} else {
101
+
req = result;
102
+
}
103
+
}
104
+
return route(req, srv);
105
+
};
106
+
107
+
type Routes = Record<
108
+
string,
109
+
Record<string, Bun.RouterTypes.RouteHandler<string>>
110
+
>;
111
+
export const applyMiddlewareAll = (
112
+
fns: Middleware[],
113
+
routes: Routes,
114
+
): Routes => {
115
+
return Object.fromEntries(
116
+
Object.entries(routes).map(([path, route]) => {
117
+
return [
118
+
path,
119
+
Object.fromEntries(
120
+
Object.entries(route).map(([method, handler]) => {
121
+
return [method, applyMiddleware(fns, handler)];
122
+
}),
123
+
),
124
+
];
125
+
}),
126
+
);
127
+
};