commit d7d699059ff12d41f311c240b0b159c1dc37c9f6 Author: retoor Date: Fri Sep 26 13:56:55 2025 +0200 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4df22c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.sh +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8664cdf --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +# Makefile for rproc + +CC = gcc +# Compiling with optimizations and all warnings enabled for robustness. +CFLAGS = -Wall -Wextra -O2 -std=c11 +# No special libraries needed for linking. +LDFLAGS = +TARGET = rproc + +.PHONY: all clean install uninstall + +all: $(TARGET) + +$(TARGET): main.c + $(CC) $(CFLAGS) -o $(TARGET) main.c $(LDFLAGS) + +clean: + rm -f $(TARGET) *.log + +# Installs the application and the systemd service file. +# Requires sudo privileges. +install: $(TARGET) + @echo "Installing rproc binary to /usr/local/bin..." + sudo cp $(TARGET) /usr/local/bin/ + @echo "Installing systemd service file to /etc/systemd/system..." + sudo cp rproc.service /etc/systemd/system/ + @echo "Reloading systemd daemon..." + sudo systemctl daemon-reload + @echo "Installation complete." + @echo "IMPORTANT: Please edit /etc/systemd/system/rproc.service and set the correct WorkingDirectory." + @echo "Then run 'sudo systemctl daemon-reload' again." + +# Uninstalls the application and the systemd service file. +# Requires sudo privileges. +uninstall: + @echo "Stopping and disabling rproc service..." + sudo systemctl stop rproc.service || true + sudo systemctl disable rproc.service || true + @echo "Removing rproc binary from /usr/local/bin..." + sudo rm -f /usr/local/bin/rproc + @echo "Removing systemd service file..." + sudo rm -f /etc/systemd/system/rproc.service + @echo "Reloading systemd daemon..." + sudo systemctl daemon-reload + @echo "Uninstallation complete." diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c01cac --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# rproc - A Zero-Configuration Process Manager + +**Authored by:** retoor +**Version:** 1.1 +**Date:** 2025-09-26 + +--- + +## 1. Overview + +**rproc** is a lightweight, file-based, and zero-configuration process manager designed for simplicity and robustness. It monitors a single directory, treating the presence, absence, and modification of executable shell scripts (`.sh` files) as the source of truth for managing long-running processes. + +Its design is predicated on providing a resilient service management layer with minimal operational overhead. + +### Key Advantages + +* **Simplicity and Intuitiveness:** No complex commands, APIs, or configuration files are required. Process management is performed using standard shell commands (`touch`, `rm`, `mv`, `chmod`), making it immediately accessible. +* **Extreme Resource Efficiency:** As a compiled C binary using event-driven I/O (`inotify`, `select`), `rproc` has a negligible memory and CPU footprint. It consumes system resources only when actively handling a file event or a process state change. +* **High Portability:** The application is a single, self-contained executable with no external dependencies beyond the standard C library. This allows it to be deployed on nearly any modern Linux system without an installation procedure. +* **GitOps-Friendly Workflow:** Because the desired state of all processes is defined by files on disk, `rproc` integrates perfectly with version control systems. Managing services can be as simple as a `git pull` in the working directory to automatically add, remove, or restart processes based on the committed changes. + +--- + +## 2. Core Concepts + +The fundamental principle of `rproc` is that the state of the executable `.sh` files in its working directory directly represents the desired state of the managed processes. + +- **To run a process:** Create an executable `.sh` file. +- **To stop a process:** Delete the `.sh` file. +- **To restart a process:** Modify the `.sh` file. + +`rproc` uses the Linux `inotify` subsystem to watch for these file system events in real-time and acts accordingly. + +--- + +## 3. Installation + +`rproc` is a single-file C application. + +### Compilation + +Compile the source code using a standard C compiler like GCC: + +```bash +gcc -Wall -Wextra -O2 -std=c11 -o rproc rproc.c diff --git a/main.c b/main.c new file mode 100644 index 0000000..9002e51 --- /dev/null +++ b/main.c @@ -0,0 +1,495 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define EVENT_SIZE (sizeof(struct inotify_event)) +#define EVENT_BUF_LEN (1024 * (EVENT_SIZE + 16)) +#define LOG_MAX_SIZE (10 * 1024 * 1024) +#define CRASH_RESTART_DELAY 15 +#define TERM_TIMEOUT 5 +#define APP_LOG_FILE "rproc.log" +#define PID_LOCK_FILE "/tmp/rproc.pid" +#define MAX_LOG_LINE_LEN 4096 + +typedef struct Process { + pid_t pid; + char* script_name; + time_t crashed_at; + struct Process* next; +} Process; + +static Process* process_list = NULL; +static volatile sig_atomic_t running = 1; + +static void app_log(const char* format, ...); +static void terminate_process(pid_t pid, const char* script_name); +static void remove_process_by_pid(pid_t pid); +static void start_script(const char* script_name); + +static void check_and_truncate_file(const char* path, off_t max_size) { + struct stat st; + if (stat(path, &st) == 0) { + if (st.st_size > max_size) { + if (truncate(path, 0) == -1) { + fprintf(stderr, "rproc warning: could not truncate %s: %s\n", path, strerror(errno)); + } + } + } +} + +static void app_log(const char* format, ...) { + check_and_truncate_file(APP_LOG_FILE, LOG_MAX_SIZE); + FILE* fp = fopen(APP_LOG_FILE, "a"); + if (!fp) return; + + time_t now = time(NULL); + char time_buf[20]; + strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", localtime(&now)); + + fprintf(fp, "[%s] ", time_buf); + va_list args; + va_start(args, format); + vfprintf(fp, format, args); + va_end(args); + fputc('\n', fp); + fclose(fp); +} + +static void add_process(pid_t pid, const char* script_name) { + Process* p = malloc(sizeof(Process)); + if (!p) { + app_log("FATAL: malloc failed in add_process."); + exit(EXIT_FAILURE); + } + p->pid = pid; + p->script_name = strdup(script_name); + if (!p->script_name) { + app_log("FATAL: strdup failed for script %s.", script_name); + free(p); + exit(EXIT_FAILURE); + } + p->crashed_at = 0; + p->next = process_list; + process_list = p; +} + +static Process* find_process_by_pid(pid_t pid) { + for (Process* p = process_list; p; p = p->next) { + if (p->pid == pid) return p; + } + return NULL; +} + +static Process* find_process_by_name(const char* script_name) { + for (Process* p = process_list; p; p = p->next) { + if (strcmp(p->script_name, script_name) == 0) return p; + } + return NULL; +} + +static void remove_process_by_pid(pid_t pid) { + Process** current = &process_list; + while (*current) { + Process* entry = *current; + if (entry->pid == pid) { + *current = entry->next; + free(entry->script_name); + free(entry); + return; + } + current = &entry->next; + } +} + +static void terminate_process(pid_t pid, const char* script_name) { + if (pid <= 0) { + if (pid == -1) { + remove_process_by_pid(pid); + } + return; + } + + app_log("Stopping process for '%s' (PID: %d)", script_name, pid); + if (kill(pid, SIGTERM) == -1) { + if (errno == ESRCH) { + app_log("Process %d for '%s' did not exist. Removing from tracking.", pid, script_name); + remove_process_by_pid(pid); + } + return; + } + + for (int i = 0; i < TERM_TIMEOUT; ++i) { + int status; + const pid_t result = waitpid(pid, &status, WNOHANG); + if (result == pid) { + app_log("Process %d for '%s' terminated gracefully.", pid, script_name); + remove_process_by_pid(pid); + return; + } + if (result == -1 && errno != ECHILD) { + remove_process_by_pid(pid); + return; + } + sleep(1); + } + + app_log("Process %d for '%s' did not terminate in time. Sending SIGKILL.", pid, script_name); + if (kill(pid, SIGKILL) == -1 && errno == ESRCH) { + app_log("Process %d for '%s' disappeared before SIGKILL.", pid, script_name); + } + waitpid(pid, NULL, 0); + remove_process_by_pid(pid); +} + +static void create_slug_from_script_name(const char* script_name, char* slug_buffer, size_t buffer_size) { + strncpy(slug_buffer, script_name, buffer_size - 1); + slug_buffer[buffer_size - 1] = '\0'; + + char* dot = strrchr(slug_buffer, '.'); + if (dot && strcmp(dot, ".sh") == 0) { + *dot = '\0'; + } + + for (char* p = slug_buffer; *p; ++p) { + if (*p == '_') { + *p = '-'; + } + } +} + +static void start_script(const char* script_name) { + Process *existing = find_process_by_name(script_name); + if (existing && existing->pid > 0) { + app_log("Script '%s' is already running with PID %d.", script_name, existing->pid); + return; + } + + app_log("Starting script: '%s'", script_name); + const pid_t pid = fork(); + if (pid == -1) { + app_log("Failed to fork for script '%s': %s", script_name, strerror(errno)); + return; + } + + if (pid == 0) { + char slug[128]; + char new_proc_name[256]; + create_slug_from_script_name(script_name, slug, sizeof(slug)); + snprintf(new_proc_name, sizeof(new_proc_name), "rproc-%s-process", slug); + + if (prctl(PR_SET_NAME, new_proc_name, 0, 0, 0) == -1) { + perror("prctl"); + } + + char stdout_log[FILENAME_MAX], stderr_log[FILENAME_MAX]; + snprintf(stdout_log, sizeof(stdout_log), "%s.stdout.log", script_name); + snprintf(stderr_log, sizeof(stderr_log), "%s.stderr.log", script_name); + + check_and_truncate_file(stdout_log, LOG_MAX_SIZE); + check_and_truncate_file(stderr_log, LOG_MAX_SIZE); + + if (!freopen(stdout_log, "a", stdout) || !freopen(stderr_log, "a", stderr)) { + exit(127); + } + + char* const argv[] = {"/bin/bash", (char*)script_name, NULL}; + char* const envp[] = { "PATH=/usr/bin:/bin", NULL }; + execve("/bin/bash", argv, envp); + + fprintf(stderr, "Failed to execute script '%s': %s\n", script_name, strerror(errno)); + exit(127); + } + + add_process(pid, script_name); + app_log("Started '%s' with PID %d", script_name, pid); +} + +static void stop_script(const char* script_name) { + Process* p = find_process_by_name(script_name); + if (p) { + terminate_process(p->pid, p->script_name); + } +} + +static void handle_sigchld(int sig) { + (void)sig; + int saved_errno = errno; + int status; + pid_t pid; + + while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { + Process* p = find_process_by_pid(pid); + if (p) { + if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { + app_log("Script '%s' (PID: %d) crashed with status %d.", p->script_name, pid, WEXITSTATUS(status)); + p->crashed_at = time(NULL); + p->pid = -1; + } else if (WIFSIGNALED(status)) { + app_log("Script '%s' (PID: %d) terminated by signal %d.", p->script_name, pid, WTERMSIG(status)); + p->crashed_at = time(NULL); + p->pid = -1; + } else { + app_log("Script '%s' (PID: %d) exited cleanly.", p->script_name, pid); + remove_process_by_pid(pid); + } + } + } + errno = saved_errno; +} + +static void handle_shutdown_signals(int sig) { + (void)sig; + char msg[] = "Shutdown signal received.\n"; + ssize_t bytes_written = write(STDOUT_FILENO, msg, sizeof(msg) - 1); + (void)bytes_written; + running = 0; +} + +static void setup_signal_handlers(void) { + struct sigaction sa_chld = {0}, sa_term = {0}; + sa_chld.sa_handler = handle_sigchld; + sa_chld.sa_flags = SA_RESTART | SA_NOCLDSTOP; + sigemptyset(&sa_chld.sa_mask); + if (sigaction(SIGCHLD, &sa_chld, 0) == -1) { + perror("sigaction SIGCHLD"); + exit(EXIT_FAILURE); + } + + sa_term.sa_handler = handle_shutdown_signals; + sigemptyset(&sa_term.sa_mask); + if (sigaction(SIGTERM, &sa_term, 0) == -1 || sigaction(SIGINT, &sa_term, 0) == -1) { + perror("sigaction SIGTERM/SIGINT"); + exit(EXIT_FAILURE); + } +} + +static void run_daemon(void) { + DIR* d = opendir("."); + if (d) { + struct dirent* dir; + while ((dir = readdir(d)) != NULL) { + size_t len = strlen(dir->d_name); + if (len > 3 && strcmp(dir->d_name + len - 3, ".sh") == 0) { + start_script(dir->d_name); + } + } + closedir(d); + } + + int fd = inotify_init1(IN_NONBLOCK); + if (fd < 0) { + app_log("inotify_init1 failed: %s", strerror(errno)); + return; + } + inotify_add_watch(fd, ".", IN_CREATE | IN_DELETE | IN_MODIFY | IN_MOVED_TO | IN_MOVED_FROM); + app_log("Daemon started. Monitoring current directory."); + + while (running) { + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(fd, &read_fds); + struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; + + int retval = select(fd + 1, &read_fds, NULL, NULL, &tv); + + if (retval > 0 && FD_ISSET(fd, &read_fds)) { + char buffer[EVENT_BUF_LEN]; + ssize_t length = read(fd, buffer, EVENT_BUF_LEN); + if (length > 0) { + for (int i = 0; i < length; ) { + struct inotify_event* event = (struct inotify_event*)&buffer[i]; + if (event->len) { + size_t name_len = strlen(event->name); + if (name_len > 3 && strcmp(event->name + name_len - 3, ".sh") == 0) { + if (event->mask & (IN_CREATE | IN_MOVED_TO)) { + app_log("Detected new script: '%s'", event->name); + start_script(event->name); + } else if (event->mask & IN_MODIFY) { + app_log("Detected modification: '%s'. Restarting.", event->name); + stop_script(event->name); + start_script(event->name); + } else if (event->mask & (IN_DELETE | IN_MOVED_FROM)) { + app_log("Detected deletion: '%s'. Stopping.", event->name); + stop_script(event->name); + } + } + } + i += EVENT_SIZE + event->len; + } + } + } + + Process* p = process_list; + while (p) { + Process* next = p->next; + if (p->pid == -1 && p->crashed_at > 0) { + if (time(NULL) - p->crashed_at >= CRASH_RESTART_DELAY) { + app_log("Restarting crashed script '%s'.", p->script_name); + char* name_copy = strdup(p->script_name); + if (name_copy) { + remove_process_by_pid(p->pid); + start_script(name_copy); + free(name_copy); + } + } + } + p = next; + } + } + + app_log("Shutdown sequence initiated. Terminating all processes."); + while (process_list) { + terminate_process(process_list->pid, process_list->script_name); + } + close(fd); + app_log("Daemon stopped."); +} + +typedef struct LogFile { + char* name; + off_t offset; + struct LogFile* next; +} LogFile; + +static void run_monitor(void) { + char cwd[PATH_MAX] = {0}; + char line_buffer[PATH_MAX]; + + FILE* lock_fp = fopen(PID_LOCK_FILE, "r"); + if (lock_fp) { + if (fgets(line_buffer, sizeof(line_buffer), lock_fp)) { + if (fgets(cwd, sizeof(cwd), lock_fp)) { + cwd[strcspn(cwd, "\n")] = 0; + if (chdir(cwd) == -1) { + perror("Could not change to daemon's directory"); + fclose(lock_fp); + return; + } + } + } + fclose(lock_fp); + } + + printf("Another instance is running in %s. Attaching as a live monitor...\n", cwd); + printf("--- Tailing all *.log files. Press Ctrl+C to exit. ---\n"); + + LogFile* log_list = NULL; + int fd = inotify_init(); + if (fd < 0) { perror("inotify_init"); return; } + + inotify_add_watch(fd, ".", IN_CREATE | IN_MODIFY); + + while(1) { + DIR* d = opendir("."); + if (d) { + struct dirent* dir; + while ((dir = readdir(d)) != NULL) { + size_t len = strlen(dir->d_name); + if (len > 4 && strcmp(dir->d_name + len - 4, ".log") == 0) { + int found = 0; + for (LogFile* l = log_list; l; l = l->next) { + if (strcmp(l->name, dir->d_name) == 0) { + found = 1; + break; + } + } + if (!found) { + LogFile* new_log = malloc(sizeof(LogFile)); + new_log->name = strdup(dir->d_name); + new_log->offset = 0; + new_log->next = log_list; + log_list = new_log; + } + } + } + closedir(d); + } + + for (LogFile* l = log_list; l; l = l->next) { + FILE* fp = fopen(l->name, "r"); + if (fp) { + fseek(fp, l->offset, SEEK_SET); + char line[MAX_LOG_LINE_LEN]; + while (fgets(line, sizeof(line), fp)) { + printf("[%s] %s", l->name, line); + } + l->offset = ftell(fp); + fclose(fp); + } + } + + fd_set read_fds; + FD_ZERO(&read_fds); + FD_SET(fd, &read_fds); + struct timeval tv = { .tv_sec = 2, .tv_usec = 0 }; + select(fd + 1, &read_fds, NULL, NULL, &tv); + + if (FD_ISSET(fd, &read_fds)) { + char buffer[EVENT_BUF_LEN]; + ssize_t bytes_read = read(fd, buffer, EVENT_BUF_LEN); + (void)bytes_read; + } + } +} + +int main(void) { + int pid_fd = open(PID_LOCK_FILE, O_CREAT | O_RDWR, 0666); + if (pid_fd == -1) { + perror("Could not open PID lock file"); + return 1; + } + + if (flock(pid_fd, LOCK_EX | LOCK_NB) == -1) { + if (errno == EWOULDBLOCK) { + close(pid_fd); + run_monitor(); + } else { + perror("flock"); + close(pid_fd); + return 1; + } + } else { + if (ftruncate(pid_fd, 0) == -1) { + perror("Could not truncate PID lock file"); + goto cleanup_lock; + } + + char cwd[PATH_MAX]; + if (!getcwd(cwd, sizeof(cwd))) { + perror("Could not get current working directory"); + goto cleanup_lock; + } + + char lock_content[PATH_MAX + 16]; + int len = snprintf(lock_content, sizeof(lock_content), "%d\n%s\n", getpid(), cwd); + if (write(pid_fd, lock_content, len) != (long int)len) { + perror("Could not write to PID lock file"); + goto cleanup_lock; + } + + setup_signal_handlers(); + run_daemon(); + + cleanup_lock: + flock(pid_fd, LOCK_UN); + close(pid_fd); + unlink(PID_LOCK_FILE); + } + + return 0; +} diff --git a/rproc b/rproc new file mode 100755 index 0000000..969a07e Binary files /dev/null and b/rproc differ diff --git a/rproc.service b/rproc.service new file mode 100644 index 0000000..b3a6f71 --- /dev/null +++ b/rproc.service @@ -0,0 +1,26 @@ +[Unit] +Description=rproc - Zero-Config C Process Runner +# Ensures the service starts after the system is ready for general use. +After=network.target + +[Service] +# The service is simple and does not fork. systemd handles daemonization. +Type=simple + +# --- IMPORTANT --- +# Change this path to the directory where your .sh scripts are located. +WorkingDirectory=/opt/scripts + +# The command to start the service. +ExecStart=/usr/local/bin/rproc + +# The service will be automatically restarted if it fails. +Restart=on-failure + +# On stop, systemd will only send a signal to the main rproc process. +# This allows rproc to handle the graceful shutdown of its children itself. +KillMode=process + +[Install] +# This enables the service to be started at boot. +WantedBy=multi-user.target