Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 4 additions & 76 deletions .github/workflows/cli-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,87 +42,15 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build packages
run: |
pnpm --filter @slopus/happy-wire --fail-if-no-match build
pnpm --filter happy --fail-if-no-match build

# happy-server-self-host ships only a bundled runtime (no sources), and the
# bundler is bun.
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.2.22

# `happy server` resolves the self-host runtime and bundled webapp from
# happy-server-self-host. Its build bundles packages/happy-server's
# standalone entrypoint and copies prisma/ in, so it must run before pack.
- name: Build happy-server-self-host runtime + bundle webapp
run: |
pnpm --filter happy-server-self-host --fail-if-no-match build
pnpm --filter happy-server-self-host --fail-if-no-match run bundle:webapp

- name: Pack packages
run: |
pnpm --filter @slopus/happy-wire --fail-if-no-match pack --pack-destination packages/happy-wire
pnpm --filter happy --fail-if-no-match pack --pack-destination packages/happy-cli
pnpm --filter happy-server-self-host --fail-if-no-match pack --pack-destination packages/happy-server-self-host

- name: Install packed packages globally
run: |
WIRE_PACKAGE_FILE=$(ls packages/happy-wire/*.tgz)
HAPPY_PACKAGE_FILE=$(ls packages/happy-cli/*.tgz)
SERVER_PACKAGE_FILE=$(ls packages/happy-server-self-host/*.tgz)
npm install -g "./$WIRE_PACKAGE_FILE" "./$SERVER_PACKAGE_FILE" "./$HAPPY_PACKAGE_FILE"

- name: Test binary execution
run: |
# Test that the binary starts successfully
echo "Testing happy --help..."
timeout 30s happy --help || {
echo "Error: happy --help failed or timed out"
exit 1
}

echo "Testing happy --version..."
timeout 10s happy --version || {
echo "Error: happy --version failed or timed out"
exit 1
}

echo "Testing happy doctor..."
timeout 30s happy doctor || {
echo "Error: happy doctor failed or timed out"
exit 1
}

echo "Testing happy daemon status..."
timeout 10s happy daemon status || {
echo "Error: happy daemon status failed or timed out"
exit 1
}

echo "Binary smoke test passed on Linux!"

- name: Test packaged server (happy server)
run: |
export HAPPY_HOME_DIR="$(mktemp -d)"
timeout 90s happy server --port 4505 --host 127.0.0.1 --no-persist --reset > /tmp/happy-server.log 2>&1 &
SERVER_PID=$!
UP=0
for i in $(seq 1 60); do
if curl -sf -m 2 http://127.0.0.1:4505/ -o /dev/null; then UP=1; echo "server up after ${i}s"; break; fi
sleep 1
done
# Exercise the Prisma native query engine via a DB-backed read.
PK=$(node -e "console.log(Buffer.alloc(32).toString('base64'))")
BODY=$(curl -s -m 5 -G http://127.0.0.1:4505/v1/auth/request/status --data-urlencode "publicKey=$PK" || true)
echo "auth/request/status -> $BODY"
kill "$SERVER_PID" 2>/dev/null || true
pkill -f happy-server || true
echo "=== happy server log ==="; cat /tmp/happy-server.log || true
if [ "$UP" != "1" ]; then echo "Error: happy server did not become healthy"; exit 1; fi
if ! echo "$BODY" | grep -q '"status"'; then echo "Error: Prisma-backed endpoint did not respond"; exit 1; fi
if grep -qiE 'PrismaClientInitializationError|could not locate.*Query Engine' /tmp/happy-server.log; then echo "Error: Prisma query engine failed to load"; exit 1; fi
echo "happy server smoke test passed (booted + Prisma query engine OK)"
- name: Test packaged self-host data safety (Linux Native)
run: pnpm --filter happy-server-self-host test:data-safety

smoke-test-windows:
runs-on: windows-latest
Expand Down
72 changes: 72 additions & 0 deletions packages/happy-server-self-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,78 @@ happy-server migrate # apply database migrations
happy-server serve # start the server
```

## Backup and restore

The supported embedded-PGlite backup boundary is a stopped filesystem snapshot
of the complete `HAPPY_HOME_DIR`. Stop `happy server` and wait for its successful
exit before creating the archive. Do not copy only `server-data/pglite`: the
complete home also contains the server URL configuration, the master secret
needed to read encrypted state, and local uploads.

```bash
set -eu

# The server must already be stopped. Use an existing backup directory outside
# HAPPY_HOME_DIR so the archive can never contain itself.
happy_home=$(realpath "$HAPPY_HOME_DIR")
backup_parent=$(realpath /path/to/existing-backup-directory)
case "$backup_parent/" in
"$happy_home/"*) echo "backup directory must be outside HAPPY_HOME_DIR" >&2; exit 1 ;;
esac
backup_dir=$(mktemp -d "$backup_parent/happy-home-backup.XXXXXX")
chmod 700 "$backup_dir"
backup="$backup_dir/happy-home.tar"
tar -C "$HAPPY_HOME_DIR" -cf "$backup" .
sha256sum "$backup" > "$backup.sha256"
tar -tf "$backup" > "$backup.members"

# Validate in a staging directory on the target filesystem. The final target
# must not exist, so a failed check cannot partially replace it.
restore_parent=$(realpath /path/to/existing-restore-parent)
case "$restore_parent/" in
"$happy_home/"*) echo "restore parent must be outside HAPPY_HOME_DIR" >&2; exit 1 ;;
esac
restore_target="$restore_parent/new-happy-home"
test ! -e "$restore_target"
test ! -L "$restore_target"
restore_staging=$(mktemp -d "$restore_parent/.happy-restore.XXXXXX")
sha256sum -c "$backup.sha256"
tar -tf "$backup" | cmp - "$backup.members"
tar -C "$restore_staging" -xf "$backup"
test -f "$restore_staging/settings.json"
test -d "$restore_staging/server-data/pglite"
test -f "$restore_staging/server-data/master-secret"
test "$(stat -c '%a' "$restore_staging/server-data/master-secret")" = 600

# Promote only after every check succeeds. The same parent keeps this rename
# on one filesystem, and -T treats the target as the exact path so the staging
# directory can never be nested beneath it.
mv -T -- "$restore_staging" "$restore_target"
HAPPY_HOME_DIR="$restore_target" happy server
```

Treat the archive as sensitive and preserve the `0600` mode of
`server-data/master-secret`. Keep the checksum and member ledger with
the backup. Validate the archive in a staging directory before promoting it to
the final empty restore target; if a check fails, leave the final target absent
and discard the staging directory only after investigating the failure.

This repository's packaged persistence, migration, recovery, and no-egress
acceptance test is Linux Native only. That test does not claim the same recovery
proof for macOS or Windows.

Maintainers with Bun, a C compiler, `unshare`, `ip`, `mount`, `script`, and
`tar` installed can run the same two-consecutive-run packaged proof used by
Linux CI with:

```bash
pnpm --filter happy-server-self-host test:data-safety
```

Set `HAPPY_DATA_SAFETY_BASELINE_REF` to a local Git ref when the proof must also
assert that `HEAD` exactly matches a fixed baseline. CI intentionally verifies
the checked-out push or pull-request revision without assuming a remote name.

## What this package is

This is the *publishing shell* around `packages/happy-server`, which is private
Expand Down
2 changes: 2 additions & 0 deletions packages/happy-server-self-host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"bundle:webapp": "node ../happy-cli/scripts/bundle-webapp.cjs --out-dir webapp",
"generate": "prisma generate --schema=prisma/schema.prisma",
"postinstall": "node scripts/postinstall.cjs",
"test:data-safety:unit": "node --test tests/packaged-data-safety.test.mjs",
"test:data-safety": "pnpm run test:data-safety:unit && node tests/packaged-data-safety-e2e.mjs --runs 2",
"prepublishOnly": "pnpm run build && pnpm run bundle:webapp && pnpm --filter happy-server --fail-if-no-match test"
},
"dependencies": {
Expand Down
176 changes: 176 additions & 0 deletions packages/happy-server-self-host/tests/network-guard.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#define _GNU_SOURCE

#include <arpa/inet.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

static void record_attempt(const char *operation, const char *target) {
const char *log_path = getenv("HAPPY_NETWORK_GUARD_LOG");
if (!log_path || !*log_path) {
return;
}

char line[1024];
int length = snprintf(line, sizeof(line), "pid=%ld operation=%s target=%s\n",
(long)getpid(), operation, target ? target : "<null>");
if (length <= 0) {
return;
}
if ((size_t)length >= sizeof(line)) {
length = (int)sizeof(line) - 1;
}

int fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600);
if (fd >= 0) {
(void)write(fd, line, (size_t)length);
(void)close(fd);
}
}

static bool is_ipv4_loopback(const struct in_addr *address) {
return (ntohl(address->s_addr) >> 24) == 127;
}

static bool is_ipv6_loopback_or_mapped_loopback(const struct in6_addr *address) {
if (IN6_IS_ADDR_LOOPBACK(address)) {
return true;
}
if (!IN6_IS_ADDR_V4MAPPED(address)) {
return false;
}
struct in_addr mapped;
memcpy(&mapped, &address->s6_addr[12], sizeof(mapped));
return is_ipv4_loopback(&mapped);
}

static bool is_loopback_sockaddr(const struct sockaddr *address) {
if (!address) {
return true;
}
if (address->sa_family == AF_INET) {
return is_ipv4_loopback(&((const struct sockaddr_in *)address)->sin_addr);
}
if (address->sa_family == AF_INET6) {
return is_ipv6_loopback_or_mapped_loopback(
&((const struct sockaddr_in6 *)address)->sin6_addr);
}
return true;
}

static void format_sockaddr(const struct sockaddr *address, char *buffer, size_t size) {
if (!address) {
snprintf(buffer, size, "<connected-socket>");
return;
}

char host[INET6_ADDRSTRLEN] = {0};
unsigned int port = 0;
if (address->sa_family == AF_INET) {
const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address;
inet_ntop(AF_INET, &ipv4->sin_addr, host, sizeof(host));
port = ntohs(ipv4->sin_port);
} else if (address->sa_family == AF_INET6) {
const struct sockaddr_in6 *ipv6 = (const struct sockaddr_in6 *)address;
inet_ntop(AF_INET6, &ipv6->sin6_addr, host, sizeof(host));
port = ntohs(ipv6->sin6_port);
} else {
snprintf(buffer, size, "family=%d", address->sa_family);
return;
}
snprintf(buffer, size, "%s:%u", host, port);
}

static bool is_allowed_name(const char *node) {
if (!node || strcmp(node, "localhost") == 0 || strcmp(node, "localhost.") == 0) {
return true;
}

struct in_addr ipv4;
if (inet_pton(AF_INET, node, &ipv4) == 1) {
return is_ipv4_loopback(&ipv4);
}

struct in6_addr ipv6;
if (inet_pton(AF_INET6, node, &ipv6) == 1) {
return is_ipv6_loopback_or_mapped_loopback(&ipv6);
}

return false;
}

int getaddrinfo(const char *node, const char *service,
const struct addrinfo *hints, struct addrinfo **result) {
static int (*real_getaddrinfo)(const char *, const char *,
const struct addrinfo *, struct addrinfo **) = NULL;
if (!real_getaddrinfo) {
real_getaddrinfo = dlsym(RTLD_NEXT, "getaddrinfo");
}
if (!is_allowed_name(node)) {
char target[768];
snprintf(target, sizeof(target), "%s:%s", node, service ? service : "<none>");
record_attempt("dns", target);
return EAI_NONAME;
}
return real_getaddrinfo(node, service, hints, result);
}

int connect(int socket_fd, const struct sockaddr *address, socklen_t length) {
static int (*real_connect)(int, const struct sockaddr *, socklen_t) = NULL;
if (!real_connect) {
real_connect = dlsym(RTLD_NEXT, "connect");
}
if ((address && (address->sa_family == AF_INET || address->sa_family == AF_INET6)) &&
!is_loopback_sockaddr(address)) {
char target[128];
format_sockaddr(address, target, sizeof(target));
record_attempt("connect", target);
errno = ENETUNREACH;
return -1;
}
return real_connect(socket_fd, address, length);
}

ssize_t sendto(int socket_fd, const void *buffer, size_t length, int flags,
const struct sockaddr *destination, socklen_t destination_length) {
static ssize_t (*real_sendto)(int, const void *, size_t, int,
const struct sockaddr *, socklen_t) = NULL;
if (!real_sendto) {
real_sendto = dlsym(RTLD_NEXT, "sendto");
}
if ((destination && (destination->sa_family == AF_INET || destination->sa_family == AF_INET6)) &&
!is_loopback_sockaddr(destination)) {
char target[128];
format_sockaddr(destination, target, sizeof(target));
record_attempt("sendto", target);
errno = ENETUNREACH;
return -1;
}
return real_sendto(socket_fd, buffer, length, flags, destination, destination_length);
}

ssize_t sendmsg(int socket_fd, const struct msghdr *message, int flags) {
static ssize_t (*real_sendmsg)(int, const struct msghdr *, int) = NULL;
if (!real_sendmsg) {
real_sendmsg = dlsym(RTLD_NEXT, "sendmsg");
}
const struct sockaddr *destination = message ? (const struct sockaddr *)message->msg_name : NULL;
if ((destination && (destination->sa_family == AF_INET || destination->sa_family == AF_INET6)) &&
!is_loopback_sockaddr(destination)) {
char target[128];
format_sockaddr(destination, target, sizeof(target));
record_attempt("sendmsg", target);
errno = ENETUNREACH;
return -1;
}
return real_sendmsg(socket_fd, message, flags);
}
Loading