|
root / src / socket.reef
socket.reef Reef 503 lines 15.5 KB
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
/******************************************************************************
                __               ____                __
               / /   ___  ____ _/ __/_____________ _/ /__
              / /   / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
             / /___/  __/ /_/ / __(__  ) /__/ /_/ / /  __/
            /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/

    (C)opyright 2025-2026, Leafscale, LLC -  https://www.leafscale.com

    Project: zyginit
   Filename: socket.reef
    Authors: Chris Tusa <chris.tusa@leafscale.com>
    License: <see LICENSE file included with this source code>
Description: Unix domain socket server for zygctl communication

******************************************************************************/

// Protocol: newline-terminated text commands and responses.
//   Request:  "COMMAND [ARG]\n"
//   Response: plain text lines, connection closed after response.

module socket

import net.unix as unix
import io.file
import io.dir
import sys.env as env
import supervisor
import config
import shutdown
import core.str
import version
import ui
import ui_render

export
    fn create_socket(path: string): int
    proc destroy_socket(path: string, server_fd: int)
    proc handle_client(server_fd: int, table: supervisor.ServiceTable)
    fn reload_requested(): bool
    proc clear_reload_flag()
    fn replace_requested(): bool
    proc clear_replace_flag()
    fn replace_wait_seconds(): int
    fn shutdown_requested(): int
    proc clear_shutdown_request()
    fn runlevel_requested(): int
    proc clear_runlevel_request()
end export

extern "C" fn zyginit_symlink(target: string, linkpath: string): int
extern "C" fn zyginit_fsync_path(path: string): int

// Reload flag — set by "reload" command, checked by main event loop
mut g_reload_requested: bool = false

fn reload_requested(): bool
    return g_reload_requested
end reload_requested

proc clear_reload_flag()
    g_reload_requested = false
end clear_reload_flag

// Replace flag — set by "replace" command, checked by main event loop.
// Wait seconds is the optional --wait=N value; 0 means "refuse immediately
// on transient state".
mut g_replace_requested: bool = false
mut g_replace_wait: int = 0

fn replace_requested(): bool
    return g_replace_requested
end replace_requested

proc clear_replace_flag()
    g_replace_requested = false
    g_replace_wait = 0
end clear_replace_flag

fn replace_wait_seconds(): int
    return g_replace_wait
end replace_wait_seconds

// Shutdown request flag — set by halt/reboot/poweroff commands,
// checked by main event loop each iteration.
// 0 = no shutdown requested; non-zero = SHUT_HALT/REBOOT/POWEROFF value.
mut g_shutdown_request: int = 0

fn shutdown_requested(): int
    return g_shutdown_request
end shutdown_requested

proc clear_shutdown_request()
    g_shutdown_request = 0
end clear_shutdown_request

// Runlevel transition request — set by `single` or `multi` socket
// commands, picked up by main loop and applied via runlevel_transition.
// Sentinel values: -1 = no request; 0 = MULTI; 1 = SINGLE (matching
// main.reef's RUNLEVEL_* constants).
mut g_runlevel_request: int = 0 - 1

fn runlevel_requested(): int
    return g_runlevel_request
end runlevel_requested

proc clear_runlevel_request()
    g_runlevel_request = 0 - 1
end clear_runlevel_request

// ============================================================================
// Socket lifecycle
// ============================================================================

// Create and bind a Unix domain socket for listening.
// Returns the server fd on success, -1 on error.
fn create_socket(path: string): int
    // Remove stale socket file if it exists. unlink failure is non-fatal —
    // the subsequent listen will fail loudly if the path is unusable.
    let unlink_rc = unix.unix_unlink(path)
    if unlink_rc < 0
        println("socket: unlink failed (non-fatal): " + path)
    end if

    let fd = unix.unix_listen(path, 5)
    if fd < 0
        println("socket: failed to listen on " + path)
        return 0 - 1
    end if

    println("socket: listening on " + path)
    return fd
end create_socket

// Close the socket and remove the socket file. Called during shutdown.
// We fsync the parent directory after unlink to force the ZFS txg
// commit before uadmin reboots — otherwise the next boot sees a stale
// socket file in /var/run and create_socket fails (unlink runs again
// but bind hits residual state). Without this, zygctl can't connect
// after a zygctl-triggered reboot.
proc destroy_socket(path: string, server_fd: int)
    println("socket: closing server fd")
    unix.unix_close(server_fd)
    let rc = unix.unix_unlink(path)
    if rc < 0
        println("socket: unlink failed: " + path)
    else
        println("socket: unlinked " + path)
    end if
    let _ = zyginit_fsync_path(path)
end destroy_socket

// ============================================================================
// Client handling
// ============================================================================

// Accept a client connection, read command, dispatch, respond, close.
proc handle_client(server_fd: int, table: supervisor.ServiceTable)
    let client_fd = unix.unix_accept(server_fd)
    if client_fd < 0
        return
    end if

    // Read the command (max 1024 bytes)
    let input = unix.unix_recv(client_fd, 1024)
    if str.length(input) == 0
        unix.unix_close(client_fd)
        return
    end if

    // Strip trailing newline/whitespace
    let cmd_raw = str.trim_ws(input)

    // Parse command and argument
    let space_pos = str.index_of(cmd_raw, " ")
    mut command = cmd_raw
    mut arg = ""

    if space_pos >= 0
        command = str.substring(cmd_raw, 0, space_pos)
        arg = str.substring(cmd_raw, space_pos + 1, str.length(cmd_raw) - space_pos - 1)
    end if

    // Dispatch
    let response = dispatch_command(command, arg, table)

    // Send response and close
    unix.unix_send(client_fd, response)
    unix.unix_close(client_fd)
end handle_client

// ============================================================================
// Command dispatch
// ============================================================================

fn dispatch_command(command: string, arg: string, table: supervisor.ServiceTable): string
    if command == "status"
        // Parse optional PLAIN modifier: "PLAIN" or "PLAIN <name>"
        mut plain = 0
        mut svc_arg = arg
        if str.length(arg) >= 5 and str.substring(arg, 0, 5) == "plain"
            if str.length(arg) == 5
                plain = 1
                svc_arg = ""
            elif str.substring(arg, 5, 1) == " "
                plain = 1
                svc_arg = str.substring(arg, 6, str.length(arg) - 6)
            end if
        end if
        return cmd_status(table, svc_arg, plain)
    elif command == "start"
        return cmd_start(table, arg)
    elif command == "stop"
        return cmd_stop(table, arg)
    elif command == "restart"
        return cmd_restart(table, arg)
    elif command == "log"
        return cmd_log(table, arg)
    elif command == "reload"
        g_reload_requested = true
        return "reload initiated\n"
    elif command == "replace"
        // Optional --wait=N argument
        mut wait_seconds = 0
        if str.length(arg) > 0
            // Parse --wait=N
            if str.substring(arg, 0, 7) == "--wait="
                let val = str.substring(arg, 7, str.length(arg) - 7)
                wait_seconds = parse_replace_int_or(val, 0)
            else
                return "error: replace: unknown argument: " + arg + "\n"
            end if
        end if
        g_replace_requested = true
        g_replace_wait = wait_seconds
        return str.concat("replace queued (wait=", str.concat(int_to_str(wait_seconds), ")\n"))
    elif command == "halt"
        g_shutdown_request = shutdown.SHUT_HALT()
        return "halting\n"
    elif command == "reboot"
        g_shutdown_request = shutdown.SHUT_REBOOT()
        return "rebooting\n"
    elif command == "poweroff"
        g_shutdown_request = shutdown.SHUT_POWEROFF()
        return "powering off\n"
    elif command == "single"
        g_runlevel_request = 1   // RUNLEVEL_SINGLE
        return "transitioning to single-user\n"
    elif command == "multi"
        g_runlevel_request = 0   // RUNLEVEL_MULTI
        return "transitioning to multi-user\n"
    elif command == "multiuser"
        // Legacy alias for backward compatibility
        g_runlevel_request = 0
        return "transitioning to multi-user\n"
    elif command == "enable"
        return cmd_enable(arg)
    elif command == "disable"
        return cmd_disable(arg)
    elif command == "list"
        // Parse optional PLAIN modifier
        mut list_plain = 0
        if arg == "plain"
            list_plain = 1
        end if
        return cmd_list(table, list_plain)
    elif command == "version"
        return "zyginit " + version.VERSION() + "\n"
    elif command == "help"
        return cmd_help()
    end if

    return "error: unknown command: " + command + "\n"
end dispatch_command

// ============================================================================
// Command implementations
// ============================================================================

fn cmd_status(table: supervisor.ServiceTable, arg: string, plain: int): string
    let count = supervisor.service_count(table)

    if str.length(arg) > 0
        // Status of a single service — honor the plain flag from the caller
        let idx = supervisor.find_service(table, arg)
        if idx < 0
            return "error: unknown service: " + arg + "\n"
        end if
        return ui_render.ui_render_status_one(table, idx, plain)
    end if

    // Status of all services
    if count == 0
        return "no services loaded\n"
    end if

    // plain=1: no banner, no escapes; plain=0: banner + colors via daemon's g_mode
    return ui_render.ui_render_status(table, 1, plain)
end cmd_status

fn cmd_start(table: supervisor.ServiceTable, name: string): string
    if str.length(name) == 0
        return "error: usage: start <service>\n"
    end if

    let idx = supervisor.find_service(table, name)
    if idx < 0
        return "error: unknown service: " + name + "\n"
    end if

    let rt = supervisor.get_runtime(table, idx)
    let state = supervisor.rt_state(rt)

    if state == supervisor.STATE_RUNNING()
        return name + " is already running\n"
    end if

    let conflict_peer = supervisor.conflicting_running_peer(table, idx)
    if conflict_peer >= 0
        let other = supervisor.service_name_at(table, conflict_peer)
        return "error: cannot start " + name + ": conflicts with running service " + other + "\n"
    end if

    if supervisor.start_service(table, idx)
        return name + " started\n"
    end if

    return "error: failed to start " + name + "\n"
end cmd_start

fn cmd_stop(table: supervisor.ServiceTable, name: string): string
    if str.length(name) == 0
        return "error: usage: stop <service>\n"
    end if

    let idx = supervisor.find_service(table, name)
    if idx < 0
        return "error: unknown service: " + name + "\n"
    end if

    if supervisor.stop_service(table, idx)
        return name + " stopping\n"
    end if

    return name + " is not running\n"
end cmd_stop

fn cmd_restart(table: supervisor.ServiceTable, name: string): string
    if str.length(name) == 0
        return "error: usage: restart <service>\n"
    end if

    let idx = supervisor.find_service(table, name)
    if idx < 0
        return "error: unknown service: " + name + "\n"
    end if

    let rt = supervisor.get_runtime(table, idx)
    let state = supervisor.rt_state(rt)

    if state == supervisor.STATE_RUNNING()
        supervisor.stop_service(table, idx)
    end if

    // Note: actual restart happens via the event loop when the stop completes
    // and restart policy triggers. For immediate restart of a stopped service:
    if state != supervisor.STATE_RUNNING()
        if supervisor.start_service(table, idx)
            return name + " restarted\n"
        end if
        return "error: failed to restart " + name + "\n"
    end if

    return name + " stop initiated (will restart via policy)\n"
end cmd_restart

fn cmd_list(table: supervisor.ServiceTable, plain: int): string
    return ui_render.ui_render_list(table, plain)
end cmd_list

fn cmd_log(table: supervisor.ServiceTable, name: string): string
    if str.length(name) == 0
        return "error: usage: log <service>\n"
    end if

    let idx = supervisor.find_service(table, name)
    if idx < 0
        return "error: unknown service: " + name + "\n"
    end if

    return supervisor.get_service_log(name)
end cmd_log

fn cmd_enable(name: string): string
    if str.length(name) == 0
        return "error: usage: enable <service>\n"
    end if

    let config_dir = env.get_env_or("ZYGINIT_CONFIG_DIR", "/etc/zyginit")
    let toml_path = str.concat(str.concat(config_dir, "/"), str.concat(name, ".toml"))

    if not file.fileExists(toml_path)
        return "error: no service definition found: " + toml_path + "\n"
    end if

    let enabled_dir = str.concat(config_dir, "/enabled.d")

    if not dir.dir_exists(enabled_dir)
        if not dir.create_dir(enabled_dir)
            return "error: cannot create " + enabled_dir + "\n"
        end if
    end if

    let link_path = str.concat(str.concat(enabled_dir, "/"), name)

    if file.fileExists(link_path)
        return name + " is already enabled\n"
    end if

    let target = str.concat("../", str.concat(name, ".toml"))
    let rc = zyginit_symlink(target, link_path)
    if rc < 0
        return "error: failed to create symlink for " + name + "\n"
    end if

    return name + " enabled\n"
end cmd_enable

fn cmd_disable(name: string): string
    if str.length(name) == 0
        return "error: usage: disable <service>\n"
    end if

    let config_dir = env.get_env_or("ZYGINIT_CONFIG_DIR", "/etc/zyginit")
    let enabled_dir = str.concat(config_dir, "/enabled.d")
    let link_path = str.concat(str.concat(enabled_dir, "/"), name)

    if not file.fileExists(link_path)
        return name + " is not enabled\n"
    end if

    if file.deleteFile(link_path)
        return name + " disabled\n"
    end if

    return "error: failed to remove " + link_path + "\n"
end cmd_disable

fn cmd_help(): string
    return "commands: status [name], start <name>, stop <name>, restart <name>, enable <name>, disable <name>, reload, replace [--wait=N], halt, reboot, poweroff, single, multiuser, log <name>, list, version, help\n"
end cmd_help

// ============================================================================
// Local helper functions
// ============================================================================

// Convert an integer to a string
fn int_to_str(n: int): string
    if n == 0
        return "0"
    end if

    mut value = n
    if n < 0
        value = 0 - n
    end if

    mut result = ""
    while value > 0
        let digit = value % 10
        result = str.concat(str.substring("0123456789", digit, 1), result)
        value = value / 10
    end while

    if n < 0
        result = str.concat("-", result)
    end if

    return result
end int_to_str

// Local int parser for replace --wait=N. Negative numbers and non-numeric
// input both yield default_val. Empty string also yields default.
fn parse_replace_int_or(s: string, default_val: int): int
    let n = str.length(s)
    if n == 0
        return default_val
    end if
    mut result = 0
    mut i = 0
    while i < n
        let c = str.char_at(s, i)
        if c < '0' or c > '9'
            return default_val
        end if
        result = result * 10 + (c - '0')
        i = i + 1
    end while
    return result
end parse_replace_int_or

end module