diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c6a65651..85aa09e4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -357,14 +357,21 @@ if(HAVE_RANDOM_H) target_compile_definitions(core PRIVATE USE_GETRANDOM) endif() -check_include_file("execinfo.h" HAVE_EXECINFO_H) -if(HAVE_EXECINFO_H) - message(STATUS "Building FTL with unwind support: YES") - target_compile_definitions(core PRIVATE USE_BACKTRACE) +# is part of GCC's libgcc — available on ALL targets (musl+glibc, static+dynamic) +# _Unwind_Backtrace() is already linked via -lgcc/-static-libgcc; no find_library needed +check_include_file("unwind.h" HAVE_UNWIND_H) +if(HAVE_UNWIND_H) + target_compile_definitions(core PRIVATE USE_UNWIND) + message(STATUS "Building FTL with _Unwind_Backtrace support: YES") else() - message(STATUS "Building FTL with unwind support: NO") + message(STATUS "Building FTL with _Unwind_Backtrace support: NO") endif() +# Embed the source root so crash backtraces can show project-relative paths +# (e.g. "src/args.c" instead of "/home/user/FTL/src/args.c"). +target_compile_definitions(core PRIVATE SOURCE_ROOT="${CMAKE_SOURCE_DIR}/") + + option(USE_READLINE "Build FTL with readline support, if available" ON) diff --git a/src/args.c b/src/args.c index 82b47ca5..241c5b13 100644 --- a/src/args.c +++ b/src/args.c @@ -76,6 +76,9 @@ #include "config/inotify.h" // get_all_supported_ciphersuites() #include "webserver/webserver.h" +// mmap(), PROT_NONE, MAP_PRIVATE, MAP_ANONYMOUS — used for intentional crash +// test +#include // defined in dnsmasq.c extern void print_dnsmasq_version(const char *yellow, const char *green, const char *bold, const char *normal); @@ -253,6 +256,40 @@ void parse_args(int argc, char *argv[]) if(argc == 2 && strcmp(argv[1], "sigrtmin") == 0) exit(sigrtmin()); + // Intentional crash test — used in CI to verify the crash handler and + // backtrace machinery work correctly. + // We use mmap(PROT_NONE) + a write to produce SIGSEGV SEGV_ACCERR. + // This is NOT undefined behaviour, NOT elided by the optimiser, and NOT + // intercepted by ASan or UBSan — unlike a raw null/invalid-pointer cast. + // handle_signals() and init_backtrace() are called here explicitly + // because parse_args() runs before main() sets them up. + if(argc == 2 && strcmp(argv[1], "crash") == 0) + { + cli_mode = true; + log_ctrl(false, true); + config.misc.addr2line.v.b = true; // Enable addr2line — config not loaded in subcommand context + handle_signals(); + void *addr = mmap(NULL, 4096u, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if(addr != MAP_FAILED) + { + volatile char *ptr = (volatile char *)addr; + *ptr = 'x'; // Write to PROT_NONE page — SIGSEGV SEGV_ACCERR + } + raise(SIGSEGV); // Fallback: mmap should never fail in practice + exit(EXIT_FAILURE); + } + + // Generate a backtrace without crashing — useful for manual inspection + // of the backtrace output on a given build/platform. + if(argc == 2 && strcmp(argv[1], "backtrace") == 0) + { + cli_mode = true; + log_ctrl(false, true); + config.misc.addr2line.v.b = true; // Enable addr2line — config not loaded in subcommand context + generate_backtrace(); + exit(EXIT_SUCCESS); + } + // If the binary name is "sqlite3" (e.g., symlink /usr/bin/sqlite3 -> /usr/bin/pihole-FTL), // we operate in drop-in mode and consume all arguments for the embedded SQLite3 engine // Also, we do this if the first argument is a file with ".db" ending diff --git a/src/daemon.c b/src/daemon.c index 45c65f03..25a4fd2c 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -156,6 +156,11 @@ void savePID(void) */ static void removePID(void) { + // Config may not have been loaded (e.g. crash/backtrace subcommands), + // in which case no PID file was ever written — nothing to remove. + if(config.files.pid.v.s == NULL) + return; + FILE *f = NULL; // Open file for writing to overwrite/empty it if((f = fopen(config.files.pid.v.s, "w")) == NULL) diff --git a/src/main.c b/src/main.c index fcc49328..a6803587 100644 --- a/src/main.c +++ b/src/main.c @@ -55,6 +55,11 @@ int main (int argc, char *argv[]) // Obtain log file location getLogFilePath(true); + // Store binary path and PIE load base address for crash-time backtrace. + // Must be called before handle_signals() so bin_path is populated before + // the first possible signal. + init_backtrace(argc > 0 ? argv[0] : NULL); + // Parse arguments // We run this also for no direct arguments // to have arg{c,v}_dnsmasq initialized diff --git a/src/signals.c b/src/signals.c index 16c33d21..82b9a163 100644 --- a/src/signals.c +++ b/src/signals.c @@ -9,9 +9,13 @@ * Please see LICENSE file for your rights under this license. */ #include "FTL.h" -#if defined(USE_BACKTRACE) -// backtrace(), backtrace_symbols() -#include +// Universal unwind backtrace via GCC's libgcc — works on glibc AND musl, static AND dynamic +#if defined(USE_UNWIND) +# include +# include // PATH_MAX +# include // SCNxPTR — portable sscanf format for uintptr_t +# include // mmap() — used for the intentional crash test subcommand +# include // dladdr() — dynamic symbol lookup from .dynsym #endif #include "signals.h" // logging routines @@ -34,6 +38,192 @@ static volatile pid_t mpid = 0; static time_t FTLstarttime = 0; volatile int exit_code = EXIT_SUCCESS; +// Binary path stored by init_backtrace() — signal-handler-safe static buffer, +// never reallocated, safe to read from any context including signal handlers +#if defined(USE_UNWIND) +static char bin_path[PATH_MAX] = { 0 }; +#endif + +#if defined(USE_UNWIND) +// PIE load base address — set once at startup by init_backtrace() using the +// __ehdr_start linker symbol (GNU ld / lld / mold). __ehdr_start points to the +// ELF header in memory, which equals the ASLR slide for PIE binaries and the +// static load address for non-PIE. Either way: file_vaddr = runtime_addr - +// exe_load_addr is exactly what addr2line expects. This is a linker symbol, not +// a libc function, so it is reliable on glibc AND musl, static AND dynamic — +// unlike dl_iterate_phdr, whose behaviour for static executables varies across +// musl versions and may leave exe_load_addr == 0. +static uintptr_t exe_load_addr = 0; +// Provided by the linker for every ELF executable (GNU ld, lld, mold, gold). +extern const char __ehdr_start; +#endif // USE_UNWIND + +// Initialize the backtrace subsystem. +// Must be called early in main() (before handle_signals()) so that bin_path +// and exe_load_addr are ready when the first crash handler fires. +void init_backtrace(const char *argv0) +{ +#if defined(USE_UNWIND) + // /proc/self/exe gives the canonical absolute path even when argv[0] is + // a relative path, a bare binary name, or a symlink. + ssize_t len = readlink("/proc/self/exe", bin_path, sizeof(bin_path) - 1u); + if(len > 0) + bin_path[len] = '\0'; + else if(argv0 != NULL) + { + strncpy(bin_path, argv0, sizeof(bin_path) - 1u); + bin_path[sizeof(bin_path) - 1u] = '\0'; + } + + // Cache the PIE load base address for addr2line offset adjustment. + // __ehdr_start is a linker symbol pointing at the ELF header in memory, + // which equals the ASLR load base for PIE binaries. + exe_load_addr = (uintptr_t)&__ehdr_start; +#else + // Unused, cannot generate backtraces right now on non-gcc targets — + // this silences a warning about unused parameters in this case + (void)argv0; +#endif // USE_UNWIND +} + +#if defined(USE_UNWIND) +// State carried through each _Unwind_Backtrace callback invocation +struct unwind_state { + void **frames; + int count; + int max; +}; + +// Callback invoked by _Unwind_Backtrace for each frame on the call stack. +// Signal-handler-safe: no heap allocation, no stdio, no locks. +static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context *ctx, void *arg) +{ + struct unwind_state *state = (struct unwind_state *)arg; + if(state->count >= state->max) + return _URC_END_OF_STACK; + uintptr_t ip = _Unwind_GetIP(ctx); + if(ip == 0) + return _URC_END_OF_STACK; + // Subtract 1 so the address points at the call instruction rather than + // the return address — addr2line then resolves the correct source line. + state->frames[state->count++] = (void *)(ip - 1u); + return _URC_NO_REASON; +} +#endif // USE_UNWIND (unwind_callback) + +#if defined(USE_UNWIND) +// Look up which /proc/self/maps entry contains addr and copy the basename +// of the mapped file (e.g. "libc.so.6", "[vdso]") into buf. +// buf is left empty when the address is not found or has no path. +static void find_mapping_name(const void *addr, char *buf, const size_t buflen) +{ + FILE *maps = fopen("/proc/self/maps", "r"); + if(maps == NULL) + return; + + const uintptr_t a = (uintptr_t)addr; + char line[512]; + while(fgets(line, sizeof(line), maps) != NULL) + { + uintptr_t start = 0, end = 0; + char path[256] = { 0 }; + // Format: start-end perms offset dev inode [path] + // Use %*s (not %*llx etc.) to avoid the GNU -Wformat= restriction + // that forbids combining the assignment-suppression modifier with a + // length modifier. We only care about start, end, and path. + const int n = sscanf(line, "%" SCNxPTR "-%" SCNxPTR " %*s %*s %*s %*s %255s", + &start, &end, path); + if(n >= 2 && a >= start && a < end && path[0] != '\0') + { + const char *base = strrchr(path, '/'); + strncpy(buf, base ? base + 1 : path, buflen - 1u); + buf[buflen - 1u] = '\0'; + break; + } + } + fclose(maps); +} + +// Log one backtrace frame as a single line. +// Resolved: " #N func_name src/file.c:line" +// Unresolved: " #N 0xADDRESS (reason)" +static void log_frame(const int idx, const void *addr, const void *rel_addr) +{ + if(!config.misc.addr2line.v.b) + { + log_info(" #%-2i %p (addr2line disabled via config)", idx, addr); + return; + } + if(bin_path[0] == '\0') + { + log_info(" #%-2i %p (binary path unknown)", idx, addr); + return; + } + + char cmd[512]; + snprintf(cmd, sizeof(cmd), "addr2line -f -e %s %p", bin_path, rel_addr); + + FILE *fp = popen(cmd, "r"); + if(fp == NULL) + { + log_info(" #%-2i %p (addr2line not available)", idx, addr); + return; + } + + char func[256] = { 0 }, loc[256] = { 0 }; + if(fgets(func, sizeof(func), fp) != NULL) + { + char *nl = strchr(func, '\n'); + if(nl != NULL) *nl = '\0'; + } + if(fgets(loc, sizeof(loc), fp) != NULL) + { + char *nl = strchr(loc, '\n'); + if(nl != NULL) *nl = '\0'; + } + pclose(fp); + + if(strcmp(func, "??") == 0) + { + // addr2line found nothing — the frame is in a shared library or a + // stripped section. Try dladdr() which reads .dynsym, the dynamic + // symbol table present in every shared library (even stripped ones). + Dl_info dl = { 0 }; + if(dladdr(addr, &dl) != 0 && dl.dli_sname != NULL) + { + // Library basename (e.g. "libc.so.6") + const char *lib = dl.dli_fname ? strrchr(dl.dli_fname, '/') : NULL; + const char *libname = lib ? lib + 1 : (dl.dli_fname ? dl.dli_fname : "?"); + // Byte offset from the nearest preceding symbol + const uintptr_t offset = (uintptr_t)addr - (uintptr_t)dl.dli_saddr; + log_info(" #%-2i %p (%s %s+0x%zx)", idx, addr, libname, dl.dli_sname, (size_t)offset); + } + else + { + // dladdr() gave nothing — fall back to /proc/self/maps for at + // least the library/mapping name ([vdso], [stack], etc.). + char mapping[128] = { 0 }; + find_mapping_name(addr, mapping, sizeof(mapping)); + if(mapping[0] != '\0') + log_info(" #%-2i %p (%s, no debug info)", idx, addr, mapping); + else + log_info(" #%-2i %p (no debug info)", idx, addr); + } + return; + } + + // Strip the compile-time source root to show project-relative paths + // (e.g. "src/signals.c:42" instead of "/home/user/FTL/src/signals.c:42"). + const char *display_loc = loc; +#if defined(SOURCE_ROOT) + if(strncmp(loc, SOURCE_ROOT, sizeof(SOURCE_ROOT) - 1u) == 0) + display_loc = loc + sizeof(SOURCE_ROOT) - 1u; +#endif + + log_info(" #%-2i %-30s %s", idx, func, display_loc); +} +#endif // USE_UNWIND + volatile sig_atomic_t thread_cancellable[THREADS_MAX] = { false }; const char * const thread_names[THREADS_MAX] = { "database", @@ -54,91 +244,26 @@ static char * __attribute__ ((nonnull (1))) getthread_name(char buffer[16]) return buffer; } -#if defined(USE_BACKTRACE) -static void print_addr2line(const char *symbol, const void *address, const int j, const void *offset) -{ - // Only do this analysis for our own binary (skip trying to analyse libc.so, etc.) - if(strstr(symbol, BINARY_NAME) == NULL) - return; - // Find first occurrence of '(' or ' ' in the obtaned symbol string and - // assume everything before that is the file name. (Don't go beyond the - // string terminator \0) - int p = 0; - while(symbol[p] != '(' && symbol[p] != ' ' && symbol[p] != '\0') - p++; - - // Compute address cleaned by binary offset - void *addr = (void*)(address-offset); - - // Invoke addr2line command and get result through pipe - char addr2line_cmd[256]; - snprintf(addr2line_cmd, sizeof(addr2line_cmd), "addr2line %p -e %.*s", addr, p, symbol); - FILE *addr2line = NULL; - char linebuffer[512]; - if(config.misc.addr2line.v.b && - (addr2line = popen(addr2line_cmd, "r")) != NULL && - fgets(linebuffer, sizeof(linebuffer), addr2line) != NULL) - { - char *pos; - // Strip possible newline at the end of the addr2line output - if ((pos=strchr(linebuffer, '\n')) != NULL) - *pos = '\0'; - } - else - { - snprintf(linebuffer, sizeof(linebuffer), "N/A (%p -> %s)", addr, addr2line_cmd); - } - // Log result - log_info("L[%04i]: %s", j, linebuffer); - - // Close pipe - if(addr2line != NULL) - pclose(addr2line); -} -#endif // USE_BACKTRACE - -// Log backtrace +// Log backtrace to the FTL log. +// Uses _Unwind_Backtrace (GCC libgcc) on all targets — glibc AND musl, +// static-pie AND dynamic, all architectures. void generate_backtrace(void) { -// Live backtrace generation is not supported by every C standard library -#if defined(USE_BACKTRACE) - // Try to obtain backtrace. This may not always be helpful, but it is better than nothing - void *buffer[255]; - const int calls = backtrace(buffer, sizeof(buffer)/sizeof(void *)); - log_info("Backtrace:"); +#if defined(USE_UNWIND) + void *frames[128]; + struct unwind_state state = { frames, 0, 128 }; + _Unwind_Backtrace(unwind_callback, &state); - char ** bcktrace = backtrace_symbols(buffer, calls); - if(bcktrace == NULL) + log_info("Backtrace (%d frames):", state.count); + for(int i = 0; i < state.count; i++) { - log_warn("Unable to obtain backtrace symbols!"); - return; + void *rel = (void *)((uintptr_t)frames[i] - exe_load_addr); + log_frame(i, frames[i], rel); } - - // Try to compute binary offset from backtrace_symbols result - void *offset = NULL; - for(int j = 0; j < calls; j++) - { - void *p1 = NULL, *p2 = NULL; - char *pend = NULL; - if((pend = strrchr(bcktrace[j], '(')) != NULL && - strstr(bcktrace[j], BINARY_NAME) != NULL && - sscanf(pend, "(+%p) [%p]", &p1, &p2) == 2) - offset = (void*)(p2-p1); - } - - for(int j = 0; j < calls; j++) - { - log_info("B[%04i]: %s", j, - bcktrace != NULL ? bcktrace[j] : "---"); - - if(bcktrace != NULL) - print_addr2line(bcktrace[j], buffer[j], j, offset); - } - free(bcktrace); #else - log_info("!!! INFO: pihole-FTL has not been compiled with glibc/backtrace support, not generating one !!!"); -#endif // USE_BACKTRACE + log_info("!!! INFO: pihole-FTL has not been compiled with unwinding support, cannot generate backtrace !!!"); +#endif } /** @@ -268,6 +393,10 @@ static void __attribute__((noreturn)) signal_handler(int sig, siginfo_t *si, voi generate_backtrace(); + // Flush stdout immediately so the backtrace is visible even if a + // subsequent fault in cleanup() kills the process before exit() runs. + fflush(stdout); + // Print content of /dev/shm ls_dir("/dev/shm"); diff --git a/src/signals.h b/src/signals.h index 61b5e8b1..e73c400a 100644 --- a/src/signals.h +++ b/src/signals.h @@ -22,6 +22,7 @@ void handle_signals(void); void handle_realtime_signals(void); pid_t main_pid(void); void thread_sleepms(const enum thread_types thread, const int milliseconds); +void init_backtrace(const char *argv0); void generate_backtrace(void); int sigtest(void); int sigrtmin(void); diff --git a/test/arch_test.sh b/test/arch_test.sh index 4edb5000..42c9da27 100755 --- a/test/arch_test.sh +++ b/test/arch_test.sh @@ -93,7 +93,25 @@ check_minimum_glibc_version() { echo "Minimum glibc version check: OK (${1})" } -if [[ "${CI_ARCH}" == "linux/amd64" ]]; then +check_crash() { + # Run the intentional-crash subcommand and capture combined stdout+stderr. + # The process exits non-zero (killed by SIGSEGV), so we suppress the error. + output="$(./pihole-FTL crash 2>&1 || true)" + if echo "$output" | grep -q "FTL crashed"; then + if echo "$output" | grep -q "Backtrace ("; then + echo "Crash handler test: OK (crash handler invoked, backtrace generated)" + else + # Handler ran but no backtrace — warn rather than fail. + # This can happen if addr2line is absent or the binary has no debug info. + echo "Crash handler test: WARNING (crash handler invoked, no backtrace)" + fi + else + echo "Crash handler test: FAILED (FTL crash handler was not invoked)" + okay=false + fi +} + +if [[ "${CI_ARCH}" == "linux/amd64" || "${CI_ARCH}" == "" ]]; then if [[ "${STATIC}" == "true" ]]; then check_machine "ELF64" "Advanced Micro Devices X86-64" @@ -102,7 +120,7 @@ if [[ "${CI_ARCH}" == "linux/amd64" ]]; then check_file "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, with debug_info, not stripped" else check_machine "ELF64" "Advanced Micro Devices X86-64" - check_libs "[libgmp.so.10] [libidn2.so.0] [libc.musl-x86_64.so.1]" + check_libs "[libgmp.so.10] [libidn2.so.0] [libgcc_s.so.1] [libc.musl-x86_64.so.1]" check_file "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, with debug_info, not stripped" fi @@ -168,6 +186,8 @@ else fi +check_crash + if [[ "${okay}" == "false" ]]; then echo "Binary checks failed" exit 1