From d7d699059ff12d41f311c240b0b159c1dc37c9f6 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 26 Sep 2025 13:56:55 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 2 + Makefile | 45 +++++ README.md | 45 +++++ main.c | 495 ++++++++++++++++++++++++++++++++++++++++++++++++++ rproc | Bin 0 -> 27008 bytes rproc.service | 26 +++ 6 files changed, 613 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 main.c create mode 100755 rproc create mode 100644 rproc.service 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 0000000000000000000000000000000000000000..969a07e2e495e5015740b895c31ab10b5d22213a GIT binary patch literal 27008 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 oZn^$awyZnJ$)w+AUj36}{K|5r9L4`f)Bj