@edgegap/mcp 0.1.2 → 0.2.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Static checks for a game server Dockerfile and its Edgegap port config.
3
+ *
4
+ * This is the step an agent gets wrong most often and learns about latest:
5
+ * a Dockerfile that builds and runs locally, then fails on Edgegap because the
6
+ * image is arm64, the server runs as root under Unreal, the port the server
7
+ * listens on is not the port the version exposes, or the protocol is TCP where
8
+ * the transport speaks UDP. Every one of those costs a build, a push, a version
9
+ * and a deployment to discover. Checking the text first costs nothing.
10
+ *
11
+ * No network, no Docker daemon. The rules come from the Dockerfiles Edgegap
12
+ * ships in its Unity and Unreal plugins, and from the constraints the API
13
+ * enforces on app versions.
14
+ */
15
+ export const PROTOCOLS = ['UDP', 'TCP', 'TCP/UDP', 'HTTP', 'HTTPS', 'WS', 'WSS'];
16
+ /** Transport → protocol it needs on the wire. Keys are lowercase. */
17
+ const NETCODE_PROTOCOL = {
18
+ 'mirror-kcp': 'UDP',
19
+ kcp: 'UDP',
20
+ 'mirror-telepathy': 'TCP',
21
+ telepathy: 'TCP',
22
+ 'mirror-simpleweb': 'WS',
23
+ simpleweb: 'WS',
24
+ websocket: 'WS',
25
+ 'fishnet-tugboat': 'UDP',
26
+ tugboat: 'UDP',
27
+ 'netcode-for-gameobjects': 'UDP',
28
+ ngo: 'UDP',
29
+ 'unity-transport': 'UDP',
30
+ utp: 'UDP',
31
+ 'photon-fusion': 'UDP',
32
+ litenetlib: 'UDP',
33
+ enet: 'UDP',
34
+ 'unreal-netdriver': 'UDP',
35
+ 'godot-enet': 'UDP',
36
+ 'godot-websocket': 'WS',
37
+ };
38
+ export const NETCODE_NAMES = Object.keys(NETCODE_PROTOCOL);
39
+ /** Edgegap's own plugin Dockerfiles, lightly commented. Returned when the
40
+ * agent has none yet, or has one that fails, so it starts from known-good. */
41
+ export const REFERENCE_DOCKERFILES = {
42
+ unity: [
43
+ 'FROM ubuntu:22.04',
44
+ '',
45
+ 'ARG DEBIAN_FRONTEND=noninteractive',
46
+ '# Folder containing the Linux Dedicated Server build (ServerBuild, *_Data, UnityPlayer.so).',
47
+ 'ARG SERVER_BUILD_PATH=Builds/EdgegapServer',
48
+ '',
49
+ 'COPY ${SERVER_BUILD_PATH} /root/build/',
50
+ 'WORKDIR /root/',
51
+ 'RUN chmod +x /root/build/ServerBuild',
52
+ '',
53
+ 'RUN apt-get update && \\',
54
+ ' apt-get install -y ca-certificates && \\',
55
+ ' apt-get clean && \\',
56
+ ' update-ca-certificates',
57
+ '',
58
+ '# Documentation only; the port that matters is the one on the app version.',
59
+ 'EXPOSE 7777/udp',
60
+ '',
61
+ 'CMD ["/bin/bash", "-c", "env;/root/build/ServerBuild -batchmode -nographics $UNITY_COMMANDLINE_ARGS"]',
62
+ ].join('\n'),
63
+ unreal: [
64
+ 'FROM ubuntu:22.04',
65
+ '',
66
+ 'RUN apt-get update && \\',
67
+ ' apt-get install -y sudo jq curl && \\',
68
+ ' apt-get clean && \\',
69
+ ' rm -rf /var/lib/{apt,dpkg,cache,log}/',
70
+ '',
71
+ '# Unreal refuses to start as root, so run as an unprivileged user.',
72
+ 'RUN useradd -rm -d /home/ubuntu -s /bin/bash -g root -G sudo -u 1000 m -o',
73
+ '',
74
+ 'WORKDIR /app',
75
+ '# Contents of the packaged LinuxServer folder, plus StartServer.sh.',
76
+ 'COPY --chown=m:sudo . /app',
77
+ '',
78
+ '# Scripts written on Windows carry CRLF endings, which break the shebang.',
79
+ "RUN sed -i 's/\\r$//' /app/StartServer.sh && chmod +x /app/StartServer.sh",
80
+ '',
81
+ 'USER m',
82
+ 'EXPOSE 7777/udp',
83
+ '',
84
+ 'CMD ./StartServer.sh',
85
+ ].join('\n'),
86
+ };
87
+ /** Joins backslash continuations and drops comments, keeping the start line. */
88
+ function parseDockerfile(text) {
89
+ const out = [];
90
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
91
+ let buf = '';
92
+ let start = 0;
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const raw = lines[i];
95
+ const trimmed = raw.trim();
96
+ if (!buf && (trimmed === '' || trimmed.startsWith('#')))
97
+ continue;
98
+ if (!buf)
99
+ start = i + 1;
100
+ if (trimmed.endsWith('\\')) {
101
+ buf += trimmed.slice(0, -1) + ' ';
102
+ continue;
103
+ }
104
+ buf += trimmed;
105
+ const m = buf.match(/^(\w+)\s*(.*)$/s);
106
+ if (m)
107
+ out.push({ line: start, op: m[1].toUpperCase(), args: m[2].trim() });
108
+ buf = '';
109
+ }
110
+ return out;
111
+ }
112
+ function detectEngine(text) {
113
+ if (/-batchmode|-nographics|UNITY_|UnityPlayer|\.x86_64\b/i.test(text))
114
+ return 'unity';
115
+ if (/StartServer\.sh|UnrealServer|Binaries\/Linux|LinuxServer|Server\.sh\b/i.test(text))
116
+ return 'unreal';
117
+ if (/godot|--headless|\.pck\b/i.test(text))
118
+ return 'godot';
119
+ return 'other';
120
+ }
121
+ function parseExpose(args) {
122
+ return args
123
+ .split(/\s+/)
124
+ .filter(Boolean)
125
+ .map((tok) => {
126
+ const [p, proto] = tok.split('/');
127
+ return { port: Number.parseInt(p, 10), protocol: (proto ?? 'tcp').toUpperCase() };
128
+ })
129
+ .filter((e) => Number.isFinite(e.port));
130
+ }
131
+ /** Does an Edgegap port protocol carry this EXPOSE protocol (tcp|udp)? */
132
+ function carries(edgegapProtocol, exposeProtocol) {
133
+ const p = edgegapProtocol.toUpperCase();
134
+ if (p === 'TCP/UDP')
135
+ return true;
136
+ if (exposeProtocol === 'UDP')
137
+ return p === 'UDP';
138
+ return p !== 'UDP'; // TCP, HTTP, HTTPS, WS, WSS all ride on TCP
139
+ }
140
+ export function validateServerConfig(input) {
141
+ const findings = [];
142
+ const err = (code, message, fix, line) => findings.push({ severity: 'error', code, message, fix, line });
143
+ const warn = (code, message, fix, line) => findings.push({ severity: 'warning', code, message, fix, line });
144
+ let engine = input.engine ?? 'other';
145
+ const exposed = [];
146
+ // ------------------------------------------------------------ Dockerfile --
147
+ if (input.dockerfile !== undefined) {
148
+ const text = input.dockerfile;
149
+ const ins = parseDockerfile(text);
150
+ if (!input.engine)
151
+ engine = detectEngine(text);
152
+ const froms = ins.filter((i) => i.op === 'FROM');
153
+ const finalFrom = froms[froms.length - 1];
154
+ if (!finalFrom) {
155
+ err('no-from', 'The Dockerfile has no FROM instruction.', 'Start from a Linux base image, e.g. "FROM ubuntu:22.04".');
156
+ }
157
+ else {
158
+ const platform = finalFrom.args.match(/--platform=(\S+)/)?.[1];
159
+ const image = finalFrom.args.replace(/--\S+/g, '').trim().split(/\s+/)[0] ?? '';
160
+ if (platform && platform.toLowerCase() !== 'linux/amd64' && !platform.includes('$')) {
161
+ err('wrong-platform', `Final stage is pinned to ${platform}. Edgegap runs linux/amd64 only.`, 'Use "FROM --platform=linux/amd64 ..." or drop the flag and build with "docker build --platform linux/amd64".', finalFrom.line);
162
+ }
163
+ if (/(^|\/)(arm64v8|arm32v7|arm32v6)\//i.test(image) || /[-:]arm64\b/i.test(image)) {
164
+ err('arm-base-image', `Base image "${image}" is an ARM image. Edgegap runs linux/amd64 only.`, 'Use the multi-arch or amd64 variant of the image.', finalFrom.line);
165
+ }
166
+ if (/windows|nanoserver|servercore/i.test(image)) {
167
+ err('windows-base-image', `Base image "${image}" is a Windows container. Edgegap runs Linux containers.`, 'Build a Linux dedicated server and use a Linux base such as ubuntu:22.04.', finalFrom.line);
168
+ }
169
+ if (image && image !== 'scratch' && !image.includes('$') && (!/[:@]/.test(image.split('/').pop() ?? '') || image.endsWith(':latest'))) {
170
+ warn('unpinned-base', `Base image "${image}" is unpinned or uses "latest", so rebuilds are not reproducible.`, 'Pin a version, e.g. ubuntu:22.04.', finalFrom.line);
171
+ }
172
+ }
173
+ const run = [...ins].reverse().find((i) => i.op === 'CMD' || i.op === 'ENTRYPOINT');
174
+ if (!run) {
175
+ err('no-cmd', 'No CMD or ENTRYPOINT: the container will exit as soon as it starts and the deployment will error.', 'Add a CMD that launches the server binary in the foreground.');
176
+ }
177
+ const launch = ins.filter((i) => i.op === 'CMD' || i.op === 'ENTRYPOINT').map((i) => i.args).join(' ');
178
+ if (/\.exe\b/i.test(ins.filter((i) => ['COPY', 'ADD', 'CMD', 'ENTRYPOINT'].includes(i.op)).map((i) => i.args).join(' '))) {
179
+ err('windows-binary', 'The image copies or launches a .exe. Edgegap runs Linux containers, so a Windows build cannot start.', 'Build the Linux dedicated server target (Unity: Linux Dedicated Server; Unreal: LinuxServer).');
180
+ }
181
+ if (/\b(127\.0\.0\.1|localhost)\b/.test(launch)) {
182
+ warn('loopback-bind', 'The launch command mentions 127.0.0.1/localhost. A server bound to loopback is unreachable from outside the container.', 'Bind to 0.0.0.0 (or omit the address so the server listens on all interfaces).', run?.line);
183
+ }
184
+ for (const e of ins.filter((i) => i.op === 'EXPOSE')) {
185
+ for (const p of parseExpose(e.args)) {
186
+ exposed.push({ ...p, line: e.line });
187
+ if (p.port === 22) {
188
+ warn('ssh-exposed', 'Port 22 (SSH) is exposed. Edgegap advises never exposing SSH in production images.', 'Remove EXPOSE 22 and the sshd start-up from the production image.', e.line);
189
+ }
190
+ }
191
+ }
192
+ const users = ins.filter((i) => i.op === 'USER');
193
+ const finalUser = users[users.length - 1]?.args.split(':')[0];
194
+ const runsAsRoot = !finalUser || finalUser === 'root' || finalUser === '0';
195
+ if (engine === 'unity') {
196
+ if (run && !/-batchmode/.test(launch)) {
197
+ warn('unity-no-batchmode', 'Unity server launched without -batchmode.', 'Append "-batchmode -nographics" to the server command.', run.line);
198
+ }
199
+ if (run && !/-nographics/.test(launch)) {
200
+ warn('unity-no-nographics', 'Unity server launched without -nographics; it may try to initialise a GPU the container does not have.', 'Append "-nographics" to the server command.', run.line);
201
+ }
202
+ if (!/chmod\s+(\+x|[0-7]*[157][0-7]{0,2})/.test(text)) {
203
+ warn('no-chmod', 'No "chmod +x" on the server binary. Builds copied from Windows often lose the executable bit, giving "permission denied" at start.', 'Add "RUN chmod +x /path/to/ServerBuild".');
204
+ }
205
+ }
206
+ if (engine === 'unreal') {
207
+ if (runsAsRoot) {
208
+ err('unreal-root', 'The container runs as root. Unreal Engine servers refuse to start as root and exit immediately.', 'Create a user (useradd ... -u 1000 m) and add "USER m" before CMD.', users[users.length - 1]?.line);
209
+ }
210
+ if (/\.sh\b/.test(launch) && !/sed\s+-i\s+['"]?s\/\\r\$\/\/|dos2unix/.test(text)) {
211
+ warn('crlf-script', 'The start script is not normalised to LF line endings. Scripts saved on Windows fail with "bad interpreter" or "not found".', "Add \"RUN sed -i 's/\\r$//' /app/StartServer.sh\" after the COPY.");
212
+ }
213
+ }
214
+ if (engine === 'godot' && run && !/--headless/.test(launch)) {
215
+ warn('godot-no-headless', 'Godot server launched without --headless.', 'Add "--headless" to the server command.', run.line);
216
+ }
217
+ }
218
+ // ----------------------------------------------------------------- Ports --
219
+ const ports = input.ports;
220
+ if (ports !== undefined) {
221
+ if (ports.length === 0) {
222
+ err('no-ports', 'No ports configured. Players cannot connect to a version with no ports.', 'Add the port the server listens on, e.g. {port: 7777, protocol: "UDP"}.');
223
+ }
224
+ const seenPorts = new Map();
225
+ const seenNames = new Set();
226
+ ports.forEach((p, idx) => {
227
+ const proto = p.protocol.toUpperCase();
228
+ if (!PROTOCOLS.includes(proto)) {
229
+ err('bad-protocol', `ports[${idx}].protocol "${p.protocol}" is not one Edgegap accepts.`, `Use one of: ${PROTOCOLS.join(', ')}.`);
230
+ }
231
+ if (!Number.isInteger(p.port) || p.port < 1 || p.port > 59999) {
232
+ err('port-range', `ports[${idx}].port ${p.port} is outside 1-59999.`, 'Use the internal port the server listens on inside the container.');
233
+ }
234
+ if (seenPorts.has(p.port)) {
235
+ err('duplicate-port', `Port ${p.port} is listed twice.`, 'Use one entry with protocol "TCP/UDP" if the server needs both.');
236
+ }
237
+ seenPorts.set(p.port, idx);
238
+ const name = p.name ?? 'gameport';
239
+ if (seenNames.has(name)) {
240
+ err('duplicate-port-name', `Port name "${name}" is used twice. Deployment ports are keyed by name, so one would hide the other.`, 'Give every port a distinct name, e.g. "gameport" and "webport".');
241
+ }
242
+ seenNames.add(name);
243
+ });
244
+ if (input.netcode) {
245
+ const expected = NETCODE_PROTOCOL[input.netcode.toLowerCase()];
246
+ if (!expected) {
247
+ warn('unknown-netcode', `Netcode "${input.netcode}" is not one this check knows.`, `Known values: ${NETCODE_NAMES.join(', ')}.`);
248
+ }
249
+ else if (ports.length > 0 && !ports.some((p) => {
250
+ const proto = p.protocol.toUpperCase();
251
+ return proto === expected || (proto === 'TCP/UDP' && (expected === 'UDP' || expected === 'TCP')) || (expected === 'WS' && proto === 'WSS');
252
+ })) {
253
+ err('netcode-protocol', `${input.netcode} speaks ${expected}, but no configured port uses ${expected}. Clients will time out connecting.`, `Set the game port's protocol to ${expected}.`);
254
+ }
255
+ }
256
+ // Cross-check EXPOSE against the version ports. EXPOSE is documentation,
257
+ // so a mismatch is not fatal on its own, but it is the best signal we have
258
+ // for "the server listens on a different port than the version exposes".
259
+ if (exposed.length > 0) {
260
+ for (const p of ports) {
261
+ const match = exposed.find((e) => e.port === p.port);
262
+ if (!match) {
263
+ warn('port-not-exposed', `Port ${p.port} is configured on the version but not EXPOSEd in the Dockerfile, which suggests the server listens elsewhere.`, `Confirm the server listens on ${p.port}, and add "EXPOSE ${p.port}/${p.protocol.toUpperCase() === 'UDP' ? 'udp' : 'tcp'}".`);
264
+ }
265
+ else if (!carries(p.protocol, match.protocol)) {
266
+ warn('protocol-mismatch', `Dockerfile exposes ${p.port}/${match.protocol.toLowerCase()} but the version configures it as ${p.protocol}.`, 'Make the two agree; the version setting is the one Edgegap uses.', match.line);
267
+ }
268
+ }
269
+ for (const e of exposed) {
270
+ if (e.port !== 22 && !ports.some((p) => p.port === e.port)) {
271
+ warn('exposed-not-configured', `Dockerfile exposes ${e.port}/${e.protocol.toLowerCase()} but no version port maps it, so players cannot reach it.`, `Add {port: ${e.port}, protocol: "${e.protocol}"} to the version ports if clients need it.`, e.line);
272
+ }
273
+ }
274
+ }
275
+ }
276
+ // ------------------------------------------------------------- Resources --
277
+ if (input.cpu_units !== undefined && input.cpu_units < 256) {
278
+ err('cpu-too-low', `cpu_units ${input.cpu_units} is below the 256 minimum.`, 'Use at least 256 (1024 = 1 vCPU).');
279
+ }
280
+ if (input.memory_mb !== undefined && input.memory_mb < 256) {
281
+ err('memory-too-low', `memory_mb ${input.memory_mb} is below the 256 minimum.`, 'Use at least 256.');
282
+ }
283
+ if (input.cpu_units !== undefined && input.memory_mb !== undefined && input.memory_mb > input.cpu_units * 2) {
284
+ err('memory-ratio', `memory_mb (${input.memory_mb}) exceeds twice cpu_units (${input.cpu_units}). Edgegap rejects this.`, `Lower memory_mb to ${input.cpu_units * 2} or raise cpu_units.`);
285
+ }
286
+ // ----------------------------------------------------------------- Image --
287
+ if (input.docker_tag !== undefined && (input.docker_tag === '' || input.docker_tag === 'latest')) {
288
+ err('latest-tag', `docker_tag "${input.docker_tag || '(empty)'}" is not reproducible, and Edgegap caches images by tag, so a re-pushed "latest" can deploy a stale build.`, 'Tag every build uniquely, e.g. a build ID or timestamp.');
289
+ }
290
+ if (input.docker_repository?.includes('registry.edgegap.com') && input.docker_image && !input.docker_image.includes('/')) {
291
+ err('missing-project', `docker_image "${input.docker_image}" has no project prefix. Images on registry.edgegap.com live under your project.`, 'Use "<project>/<image>"; edgegap_get_registry_credentials returns the project name.');
292
+ }
293
+ if (input.docker_repository && /^https?:\/\//.test(input.docker_repository)) {
294
+ err('repository-scheme', `docker_repository "${input.docker_repository}" includes a URL scheme.`, 'Use the bare host, e.g. "registry.edgegap.com".');
295
+ }
296
+ const errors = findings.filter((f) => f.severity === 'error');
297
+ const warnings = findings.filter((f) => f.severity === 'warning');
298
+ return { engine, errors, warnings };
299
+ }