@ankorcs/protocol 0.0.0-stage → 0.6.1

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/values.js ADDED
@@ -0,0 +1,230 @@
1
+ /** Value types that more than one wire and `agent.yml` share. */
2
+ import { z } from 'zod';
3
+ import { isIpv4, isControllerHost, SLOT_ID_MESSAGE, CONTROLLER_HOST_MESSAGE } from './checks.js';
4
+ import { DEMO_HEADERS, PROTOCOL_HEADER } from './paths.js';
5
+ import { MAX_MEMORY_MB, MIN_MEMORY_MB } from './limits.js';
6
+ /** The loopback port the agent listens on for plugins. No slot may use it. */
7
+ export const EVENT_RELAY_PORT = 4500;
8
+ /** The highest port a slot may use, for its game port and its GOTV port. */
9
+ export const MAX_PORT = 65535;
10
+ /**
11
+ * The lowest port `agent.yml` permits for a slot. A configuration front end may require a higher
12
+ * one.
13
+ */
14
+ export const AGENT_MIN_SLOT_PORT = 1;
15
+ /** A slot id: 1 to 32 letters, digits, `_` or `-`. It becomes part of the game container's name. */
16
+ export const SLOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
17
+ /**
18
+ * The shape of a node token. It has letters and digits only, so that it reads the same in a
19
+ * header, a YAML file and a shell.
20
+ */
21
+ export const NODE_TOKEN_PATTERN = /^[A-Za-z0-9]{32,128}$/;
22
+ /** What a node presents as `Authorization: Bearer <token>` on every request. */
23
+ export const NodeToken = z.string().regex(NODE_TOKEN_PATTERN, 'must be 32 to 128 letters and digits');
24
+ /**
25
+ * The id the controller picks for each server it launches. It identifies that server and its
26
+ * events from then on.
27
+ *
28
+ * The id is a lowercase UUID, new for every launch. UUIDv7 is recommended, because its time-ordered
29
+ * values keep a database index on it compact as events arrive.
30
+ */
31
+ export const AllocationId = z.uuid().regex(/^[^A-F]*$/, 'must be lowercase');
32
+ /** A slot's id, which is also part of the names of its containers. */
33
+ export const SlotId = z.string().regex(SLOT_ID_PATTERN, SLOT_ID_MESSAGE);
34
+ /** A TCP or UDP port. */
35
+ export const Port = z.number().int().min(AGENT_MIN_SLOT_PORT).max(MAX_PORT);
36
+ /** A slot's id and ports. */
37
+ export const ServerSlot = z.object({
38
+ /** The slot's id, 1 to 32 letters, digits, `_` and `-`. It is unique on its node. */
39
+ id: SlotId,
40
+ /**
41
+ * The game port, UDP. Every `port` and `tv_port` on a node is different, free on the host, and
42
+ * not {@link EVENT_RELAY_PORT}, the port the agent listens on for plugins.
43
+ * [Host networking](https://docs.ankor.sh/how-it-works/#host-networking) explains why.
44
+ */
45
+ port: Port,
46
+ /** The GOTV port, UDP, under the same rules as `port`. */
47
+ tv_port: Port,
48
+ });
49
+ /** The shape of a Game Server Login Token as Steam issues them. */
50
+ export const GSLT_PATTERN = /^[A-Za-z0-9]{10,64}$/;
51
+ /** A Game Server Login Token, which the servers of a slot log in to Steam with. */
52
+ export const Gslt = z.string().regex(GSLT_PATTERN, 'a token is 10 to 64 letters and digits');
53
+ /**
54
+ * The last ten characters of a token, which the agent reports instead of the token. They are enough
55
+ * to tell apart every token on one Steam account.
56
+ */
57
+ export const GsltHint = z.string().regex(/^[A-Za-z0-9]{10}$/, 'the last ten characters of a token');
58
+ /**
59
+ * Whether Steam accepts a slot's token.
60
+ *
61
+ * - `ok`: a logon with the token succeeded, either the agent's own or that of a server that
62
+ * turned `ready`.
63
+ * - `invalid`: Steam refused the token.
64
+ * - `unchecked`: the agent has seen neither since the token was set.
65
+ */
66
+ export const GsltState = z.enum(['ok', 'invalid', 'unchecked']);
67
+ /** A slot as `agent.yml` holds it, with its full token. */
68
+ export const ConfiguredSlot = ServerSlot.extend({
69
+ /**
70
+ * The slot's Game Server Login Token, which every server in the slot logs in with. It is 10 to 64
71
+ * letters and digits. [Game Server Login Tokens](https://docs.ankor.sh/requirements/#game-server-login-tokens)
72
+ * explains them.
73
+ */
74
+ gslt: Gslt,
75
+ });
76
+ /**
77
+ * A slot as the agent reports it, with the last ten characters of its token in place of the token.
78
+ */
79
+ export const ReportedSlot = ServerSlot.extend({
80
+ /** The last ten characters of the slot's token. */
81
+ gslt_hint: GsltHint,
82
+ });
83
+ /** A Docker image reference as `docker run` accepts it: `registry/repo:tag` or `repo@sha256:…`. */
84
+ const IMAGE_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._\-/:@]{0,254}$/;
85
+ /** A Docker image reference, as `cs2_image` and `agent_image` in `agent.yml` hold it. */
86
+ export const ImageReference = z.string().regex(IMAGE_REFERENCE_PATTERN, 'must be a Docker image reference');
87
+ /**
88
+ * MiB of memory each game container may use. CS2 installs and updates are exempt.
89
+ *
90
+ * Rejected when it is not a whole number from {@link MIN_MEMORY_MB} to {@link MAX_MEMORY_MB}.
91
+ */
92
+ export const MemoryLimit = z.number().int().min(MIN_MEMORY_MB).max(MAX_MEMORY_MB);
93
+ /**
94
+ * The repository of each official image. With `official_images_only` on, a node runs an image only
95
+ * from its kind's repository, with any tag or digest, such as `ghcr.io/ankorcs/ankor-agent:0.8`.
96
+ */
97
+ export const OFFICIAL_IMAGES = {
98
+ agent: 'ghcr.io/ankorcs/ankor-agent',
99
+ cs2: 'ghcr.io/ankorcs/ankor-cs2',
100
+ };
101
+ /**
102
+ * A dotted IPv4 address. Rejected unless it is four numbers from 0 to 255 joined by dots, each
103
+ * without leading zeros.
104
+ */
105
+ export const Ipv4 = z.string().refine((value) => isIpv4(value), 'must be an IPv4 address');
106
+ /**
107
+ * A host with an optional port and no scheme or path, such as `cs.example.com`,
108
+ * `192.168.0.10:3000` or `[fd00::1]`.
109
+ *
110
+ * Rejected when it contains whitespace, a control character, `/`, `\`, `?` or `#`. It is also
111
+ * rejected unless `http://` followed by the value parses as a URL that has a host, an optional port
112
+ * and nothing else, such as a user name.
113
+ */
114
+ export const ControllerHost = z.string().refine((value) => isControllerHost(value), CONTROLLER_HOST_MESSAGE);
115
+ /**
116
+ * A path on the controller's host. It starts with `/`, followed by URL path characters and percent
117
+ * escapes, without a query or fragment.
118
+ */
119
+ export const ControllerPath = z.string().max(255)
120
+ .regex(/^\/(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/]|%[0-9A-Fa-f]{2})*$/, 'must be a path starting with /, without a query, fragment or spaces');
121
+ /** Whether a URL carries no user name or password. */
122
+ function withoutCredentials(url) {
123
+ const parsed = URL.parse(url);
124
+ return parsed === null || (parsed.username === '' && parsed.password === '');
125
+ }
126
+ /**
127
+ * The URL finished demos are posted to. A target that needs credentials takes them in the configured
128
+ * `header`, such as `Authorization: Basic …`, which the node reports only as a hash.
129
+ *
130
+ * Rejected when the URL carries a user name or a password.
131
+ */
132
+ export const DemoUploadUrl = z.url({ protocol: /^https?$/, error: 'must be an http or https URL' }).max(255)
133
+ .refine((url) => withoutCredentials(url), 'credentials go in the header, not the URL');
134
+ /**
135
+ * Header names, in lower case, that a demo upload's configured header cannot take: the ones the
136
+ * agent sets itself on every upload, and the ones that change how the request is framed.
137
+ */
138
+ export const RESERVED_DEMO_HEADERS = [
139
+ 'host',
140
+ 'content-type',
141
+ 'content-length',
142
+ 'transfer-encoding',
143
+ 'connection',
144
+ 'keep-alive',
145
+ 'upgrade',
146
+ 'expect',
147
+ 'te',
148
+ 'trailer',
149
+ PROTOCOL_HEADER.toLowerCase(),
150
+ ...Object.values(DEMO_HEADERS).map((name) => name.toLowerCase()),
151
+ ];
152
+ /** Whether a header name, in any case, is one of the {@link RESERVED_DEMO_HEADERS}. */
153
+ function reservedDemoHeader(name) {
154
+ return RESERVED_DEMO_HEADERS.includes(name.toLowerCase());
155
+ }
156
+ /**
157
+ * One header sent with every demo upload, verbatim: any name the target expects, such as an API key
158
+ * header or `Authorization: Basic …`, except the {@link RESERVED_DEMO_HEADERS}.
159
+ */
160
+ export const DemoUploadHeader = z
161
+ .object({
162
+ /**
163
+ * The header's name, up to 64 letters, digits and `-`. Rejected when it is, in any case, one of
164
+ * {@link RESERVED_DEMO_HEADERS}, which the agent sets itself or which change how the request is
165
+ * framed.
166
+ */
167
+ name: z.string().regex(/^[A-Za-z0-9-]{1,64}$/, 'header names are up to 64 letters, digits and dashes')
168
+ .refine((name) => !reservedDemoHeader(name), 'the agent sets this header itself'),
169
+ /** The header's value, sent verbatim, without line breaks. */
170
+ value: z.string().max(1024).regex(/^[^\r\n\u0000]*$/, 'header values cannot contain line breaks'),
171
+ });
172
+ /** Whole megabits a second, from 1 to 10 Gbit/s. */
173
+ export const DemoUploadLimit = z.number().int().min(1).max(10_000);
174
+ /** Where finished demos go. The header, when set, is sent verbatim and is the only credential. */
175
+ export const DemoUpload = z.object({
176
+ /** The upload target. */
177
+ url: DemoUploadUrl,
178
+ /** A header to send with each upload. Null for none. */
179
+ header: DemoUploadHeader.nullable(),
180
+ /**
181
+ * Megabits a second an upload may send while a game server runs on the node. Null for no limit.
182
+ */
183
+ limit_mbit: DemoUploadLimit.nullable(),
184
+ });
185
+ /**
186
+ * A demo upload as the node reports it, with the header's value replaced by its hash, because the
187
+ * value is a credential that the controller set.
188
+ */
189
+ export const ReportedDemoUpload = DemoUpload.extend({
190
+ /** The header sent with each upload. Null for none. */
191
+ header: DemoUploadHeader.pick({ name: true }).extend({
192
+ /**
193
+ * The lowercase hex SHA-256 of the value's UTF-8 bytes, to compare with the value the controller
194
+ * set.
195
+ */
196
+ value_sha256: z.string().regex(/^[0-9a-f]{64}$/, 'a lowercase hex SHA-256'),
197
+ }).nullable(),
198
+ });
199
+ /**
200
+ * A demo upload as {@link ControllerSetConfig | set_config} sets it. A header without its value keeps the
201
+ * value the node has.
202
+ */
203
+ export const DemoUploadEdit = DemoUpload.extend({
204
+ /**
205
+ * A header to send with each upload. Null for none. Without `value`, the header keeps the value
206
+ * of the node's current header of that name, so the `demo_upload` of a {@link AgentHello | hello}
207
+ * can be sent back unchanged.
208
+ */
209
+ header: DemoUploadHeader.partial({ value: true }).nullable(),
210
+ });
211
+ /**
212
+ * Where a node dials the controller: a host with an optional port, whether it encrypts, and the
213
+ * paths it reaches there.
214
+ */
215
+ export const ControllerAddress = z.object({
216
+ /** The controller's host with an optional port. */
217
+ host: ControllerHost,
218
+ /** Whether the agent dials over TLS. */
219
+ tls: z.boolean(),
220
+ /**
221
+ * The path of the agent socket. It is {@link DEFAULT_SOCKET_PATH} unless `agent.yml` sets
222
+ * `controller.socket_path`.
223
+ */
224
+ socket_path: ControllerPath,
225
+ /**
226
+ * The path event batches are posted to. It is {@link DEFAULT_EVENTS_PATH} unless `agent.yml` sets
227
+ * `controller.events_path`.
228
+ */
229
+ events_path: ControllerPath,
230
+ });