summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJesper Jensen <jesper@jnsn.dev>2026-02-18 22:14:54 +0100
committerJesper Jensen <jesper@jnsn.dev>2026-02-18 22:14:54 +0100
commit8d3257fe558727a3d64902e27539b5a5c28ba49f (patch)
tree33d97cdcf4dd89f3e396b522d14edcca65106708
parent5fbbd990d6b42f5e7534cdb68fa5c368a4a69169 (diff)
Stop stalled backups
-rw-r--r--Dockerfile22
-rw-r--r--ci/build.yaml48
-rw-r--r--src/web.c245
-rw-r--r--src/web.h22
-rw-r--r--test/http.c71
5 files changed, 406 insertions, 2 deletions
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..57cc35c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62 AS build
+
+RUN apk add --no-cache gcc make musl-dev sqlite-dev libmicrohttpd-dev cjson-dev
+
+COPY . /app
+
+WORKDIR /app
+RUN make app
+
+
+FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
+
+RUN apk add --no-cache libgcc sqlite-libs libmicrohttpd cjson \
+ && mkdir /config
+
+COPY --from=build /app/app /app/app
+
+VOLUME /workdir
+VOLUME /data
+EXPOSE 9090
+
+CMD [ "/app/app", "8080" ]
diff --git a/ci/build.yaml b/ci/build.yaml
new file mode 100644
index 0000000..4d19a18
--- /dev/null
+++ b/ci/build.yaml
@@ -0,0 +1,48 @@
+resources:
+- name: repo
+ type: git
+ source:
+ uri: git@github.com:DelusionalLogic/borgflag.git
+ branch: main
+ private_key: ((vbump.borgflag-key))
+- name: final-image
+ type: registry-image
+ icon: docker
+ source:
+ repository: registry.jnsn.dev/borgflag
+ username: ((vbump.username))
+ password: ((vbump.password))
+ tag: latest
+
+jobs:
+- name: update-pipeline
+ plan:
+ - get: repo
+ trigger: true
+ - set_pipeline: self
+ file: repo/ci/build.yaml
+
+- name: build-container
+ plan:
+ - get: repo
+ trigger: true
+ - task: build-image
+ privileged: true
+ config:
+ platform: linux
+ image_resource:
+ type: registry-image
+ source:
+ repository: concourse/oci-build-task
+ inputs:
+ - name: repo
+ outputs:
+ - name: image
+ params:
+ CONTEXT: repo
+ UNPACK_ROOTFS: "true"
+ run:
+ path: build
+ - put: final-image
+ params:
+ image: image/image.tar
diff --git a/src/web.c b/src/web.c
index df2dc81..e0d6d55 100644
--- a/src/web.c
+++ b/src/web.c
@@ -278,6 +278,28 @@ int submit_report(struct App *app, char *client, char *secret, enum ReportEvent
switch(event) {
case REV_BEGIN: {
sqlite3_stmt *stmt;
+
+ // Mark any existing RUNNING backup as INTERRUPTED
+ if(sqlite3_prepare_v2(conn, "UPDATE backups SET status = 'INTERRUPTED', completed = datetime(?1, 'unixepoch', 'subsec') WHERE client = ?2 AND status = 'RUNNING'", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_double(stmt, 1, TIME_AS_FLOAT(now)) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 2, clientId) != SQLITE_OK) {
+ abort();
+ }
+
+ if(sqlite3_step(stmt) != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ stmt = NULL;
+
+ // Insert the new backup
if(sqlite3_prepare_v2(conn, "INSERT INTO backups (client, status, started) VALUES (?1, ?2, datetime(?3, 'unixepoch', 'subsec'))", -1, &stmt, NULL) != SQLITE_OK) {
abort();
}
@@ -480,6 +502,93 @@ int list_clients(struct Request *req, struct ListClientResult *result) {
return 0;
}
+int get_client_with_backups(struct Request *req, int64_t client_id, struct GetClientResult *result) {
+ sqlite3 *conn;
+ int err;
+ if((err = sqlite3_open(req->server->app->dbname, &conn)) != SQLITE_OK) {
+ abort();
+ }
+
+ result->found = 0;
+
+ // Query 1: Get client info
+ {
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id, name, secret FROM clients WHERE id = ?1", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
+ abort();
+ }
+
+ int ret = sqlite3_step(stmt);
+ if(ret == SQLITE_ROW) {
+ result->found = 1;
+ result->id = sqlite3_column_int64(stmt, 0);
+ const uint8_t *name = sqlite3_column_text(stmt, 1);
+ const uint8_t *secret = sqlite3_column_text(stmt, 2);
+ strncpy(result->name, (char*)name, CLIENT_NAME_MAX);
+ strncpy(result->secret, (char*)secret, CLIENT_SECRET_LEN);
+ } else if(ret != SQLITE_DONE) {
+ abort();
+ }
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(!result->found) {
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+ }
+
+ // Query 2: Get backups for this client
+ {
+ sqlite3_stmt *stmt;
+ if(sqlite3_prepare_v2(conn, "SELECT id, status, started, completed FROM backups WHERE client = ?1 ORDER BY started DESC LIMIT 50", -1, &stmt, NULL) != SQLITE_OK) {
+ abort();
+ }
+ if(sqlite3_bind_int64(stmt, 1, client_id) != SQLITE_OK) {
+ abort();
+ }
+
+ int ret;
+ int index = 0;
+ while((ret = sqlite3_step(stmt)) == SQLITE_ROW) {
+ if(index >= MAX_BACKUPS_DISPLAY) break;
+
+ result->backups[index].id = sqlite3_column_int64(stmt, 0);
+
+ const uint8_t *status = sqlite3_column_text(stmt, 1);
+ const uint8_t *started = sqlite3_column_text(stmt, 2);
+ const uint8_t *completed = sqlite3_column_text(stmt, 3);
+
+ strncpy(result->backups[index].status, status ? (char*)status : "", BACKUP_STATUS_MAX);
+ strncpy(result->backups[index].started, started ? (char*)started : "", BACKUP_DATETIME_MAX);
+ strncpy(result->backups[index].completed, completed ? (char*)completed : "", BACKUP_DATETIME_MAX);
+
+ index++;
+ }
+ if(ret != SQLITE_DONE && ret != SQLITE_ROW) {
+ abort();
+ }
+
+ result->backups_len = index;
+
+ if(sqlite3_finalize(stmt) != SQLITE_OK) {
+ abort();
+ }
+ }
+
+ if(sqlite3_close(conn) != SQLITE_OK) {
+ abort();
+ }
+ return 0;
+}
+
static char *render_clients_page(struct Request *request, size_t *out_len) {
int ret;
struct ListClientResult *result = malloc(sizeof(struct ListClientResult) + sizeof(struct ListClientResultClient) * 16);
@@ -511,7 +620,7 @@ static char *render_clients_page(struct Request *request, size_t *out_len) {
for (int i = 0; i < result->clients_len; i++) {
fprintf(f,
" <tr>\n"
- " <td>%s</td>\n"
+ " <td><a href=\"/client/%ld\">%s</a></td>\n"
" <td>%s</td>\n"
" <td>\n"
" <form method=\"POST\" action=\"/clients/delete\" style=\"display:inline\">\n"
@@ -520,6 +629,7 @@ static char *render_clients_page(struct Request *request, size_t *out_len) {
" </form>\n"
" </td>\n"
" </tr>\n",
+ result->clients[i].id,
result->clients[i].name,
result->clients[i].secret,
result->clients[i].id
@@ -542,6 +652,99 @@ static char *render_clients_page(struct Request *request, size_t *out_len) {
return buf;
}
+static char *render_client_detail_page(struct Request *request, int64_t client_id, size_t *out_len) {
+ struct GetClientResult *result = malloc(sizeof(struct GetClientResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY);
+ int ret = get_client_with_backups(request, client_id, result);
+ if(ret != 0) {
+ free(result);
+ return NULL;
+ }
+
+ if(!result->found) {
+ free(result);
+ return NULL;
+ }
+
+ char *buf = NULL;
+ size_t buf_len = 0;
+ FILE *f = open_memstream(&buf, &buf_len);
+ if (!f) {
+ free(result);
+ return NULL;
+ }
+
+ fprintf(f,
+ "<!DOCTYPE html>\n"
+ "<html>\n"
+ "<head>\n"
+ " <title>Client: %s</title>\n"
+ "</head>\n"
+ "<body>\n"
+ " <p><a href=\"/clients\">Clients</a> &gt; %s</p>\n"
+ " <h1>Client: %s</h1>\n"
+ " <h2>Details</h2>\n"
+ " <table border=\"1\">\n"
+ " <tr><th>ID</th><td>%ld</td></tr>\n"
+ " <tr><th>Name</th><td>%s</td></tr>\n"
+ " <tr><th>Secret</th><td>%s</td></tr>\n"
+ " </table>\n",
+ result->name,
+ result->name,
+ result->name,
+ result->id,
+ result->name,
+ result->secret
+ );
+
+ fprintf(f,
+ " <h2>Submit Report</h2>\n"
+ " <p>Use this curl command to submit a backup report:</p>\n"
+ " <pre>curl -X POST http://%s/api/report \\\n"
+ " -H \"Content-Type: application/json\" \\\n"
+ " -d '{\"client\": \"%s\", \"secret\": \"%s\", \"event\": \"begin_backup\"}'</pre>\n"
+ " <p>Replace <code>begin_backup</code> with <code>end_backup</code> to mark completion.</p>\n",
+ request->host ? request->host : "localhost",
+ result->name,
+ result->secret
+ );
+
+ fprintf(f, " <h2>Backup History</h2>\n");
+
+ if(result->backups_len == 0) {
+ fprintf(f, " <p>No backups recorded.</p>\n");
+ } else {
+ fprintf(f,
+ " <table border=\"1\">\n"
+ " <tr><th>ID</th><th>Status</th><th>Started</th><th>Completed</th></tr>\n");
+
+ for (int i = 0; i < result->backups_len; i++) {
+ fprintf(f,
+ " <tr>\n"
+ " <td>%ld</td>\n"
+ " <td>%s</td>\n"
+ " <td>%s</td>\n"
+ " <td>%s</td>\n"
+ " </tr>\n",
+ result->backups[i].id,
+ result->backups[i].status,
+ result->backups[i].started,
+ result->backups[i].completed[0] ? result->backups[i].completed : "-"
+ );
+ }
+
+ fprintf(f, " </table>\n");
+ }
+
+ fprintf(f,
+ "</body>\n"
+ "</html>\n");
+
+ fclose(f);
+ *out_len = buf_len;
+ free(result);
+ return buf;
+}
+
static enum MHD_Result create_post_collector(struct PostCollector *c) {
c->data = malloc(512);
c->size = 0;
@@ -625,6 +828,7 @@ static enum MHD_Result handler(
*ptr = request;
request->server = server;
+ request->host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Host");
if(strcmp(method, "POST") == 0) {
if(strcmp(url, "/clients") == 0 || strcmp(url, "/clients/delete") == 0) {
@@ -849,6 +1053,43 @@ static enum MHD_Result handler(
);
MHD_destroy_response(response);
return ret;
+ } else if (strncmp(url, "/client/", 8) == 0) {
+ int64_t client_id;
+ if (sscanf(url + 8, "%ld", &client_id) != 1 || client_id <= 0) {
+ // Bad Request
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ 0, "", MHD_RESPMEM_PERSISTENT);
+ ret = MHD_queue_response(connection, MHD_HTTP_BAD_REQUEST, response);
+ MHD_destroy_response(response);
+ return ret;
+ }
+
+ size_t html_len;
+ char *html = render_client_detail_page(request, client_id, &html_len);
+
+ if (!html) {
+ // Not Found
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ 0, "", MHD_RESPMEM_PERSISTENT);
+ ret = MHD_queue_response(connection, MHD_HTTP_NOT_FOUND, response);
+ MHD_destroy_response(response);
+ return ret;
+ }
+
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ html_len,
+ html,
+ MHD_RESPMEM_MUST_FREE
+ );
+ ret = MHD_add_response_header(response, "Content-Type", "text/html");
+ if(ret != MHD_YES) return ret;
+ ret = MHD_queue_response(
+ connection,
+ MHD_HTTP_OK,
+ response
+ );
+ MHD_destroy_response(response);
+ return ret;
} else {
struct MHD_Response *response = MHD_create_response_from_buffer(
strlen(server->page),
@@ -892,7 +1133,7 @@ int web_begin(struct Server *server, int port) {
server->page = PAGE;
d = MHD_start_daemon(
- MHD_USE_THREAD_PER_CONNECTION,
+ MHD_USE_THREAD_PER_CONNECTION | MHD_USE_DUAL_STACK,
port,
NULL, NULL,
&handler, server,
diff --git a/src/web.h b/src/web.h
index 95c0e27..27dcde3 100644
--- a/src/web.h
+++ b/src/web.h
@@ -41,6 +41,7 @@ struct Request {
struct PostCollector pp;
struct MHD_PostProcessor *post_proc; // NULL when using PostCollector
struct FormData form;
+ const char *host;
};
struct CreateClientResult {
@@ -66,3 +67,24 @@ struct ListClientResult {
int list_clients(struct Request *req, struct ListClientResult *result);
+#define BACKUP_STATUS_MAX 16
+#define BACKUP_DATETIME_MAX 32
+#define MAX_BACKUPS_DISPLAY 50
+
+struct BackupRecord {
+ int64_t id;
+ char status[BACKUP_STATUS_MAX];
+ char started[BACKUP_DATETIME_MAX];
+ char completed[BACKUP_DATETIME_MAX];
+};
+
+struct GetClientResult {
+ int found;
+ int64_t id;
+ char name[CLIENT_NAME_MAX];
+ char secret[CLIENT_SECRET_LEN];
+ uint16_t backups_len;
+ struct BackupRecord backups[];
+};
+
+int get_client_with_backups(struct Request *req, int64_t client_id, struct GetClientResult *result);
diff --git a/test/http.c b/test/http.c
index 8309d5e..27385ab 100644
--- a/test/http.c
+++ b/test/http.c
@@ -271,5 +271,76 @@ int main(int argc, char **argv) {
assert(code == 401); // Unauthorized
}
+ // Test: Starting a new backup should interrupt the previous RUNNING backup
+ {
+ // Begin first backup
+ curlRes = curl_easy_setopt(curl, CURLOPT_URL, "http://localhost:8080/api/report");
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_setopt(curl, CURLOPT_POST, 1L);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_setopt(curl, CURLOPT_READFUNCTION, read_from_memory);
+ assert(curlRes == CURLE_OK);
+
+ char req_buf[256];
+ snprintf(req_buf, sizeof(req_buf),
+ "{\"client\": \"client1\", \"secret\": \"%s\", \"event\": \"begin_backup\"}",
+ client_secret);
+ struct memory req_body = { .body = req_buf, .size = strlen(req_buf) };
+ curlRes = curl_easy_setopt(curl, CURLOPT_READDATA, &req_body);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_to_memory);
+ assert(curlRes == CURLE_OK);
+ struct memory body = {0};
+ curlRes = curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_perform(curl);
+ assert(curlRes == CURLE_OK);
+ long code;
+ curlRes = curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ assert(code == 204);
+ body = (struct memory){0};
+
+ progress_time(TIME_JOIN(0, 10, 0)); // Advance 10 minutes
+
+ // Begin second backup (should interrupt the first)
+ snprintf(req_buf, sizeof(req_buf),
+ "{\"client\": \"client1\", \"secret\": \"%s\", \"event\": \"begin_backup\"}",
+ client_secret);
+ req_body = (struct memory){ .body = req_buf, .size = strlen(req_buf) };
+ curlRes = curl_easy_setopt(curl, CURLOPT_READDATA, &req_body);
+ assert(curlRes == CURLE_OK);
+ body = (struct memory){0};
+ curlRes = curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_perform(curl);
+ assert(curlRes == CURLE_OK);
+ curlRes = curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ assert(code == 204);
+ body = (struct memory){0};
+
+ // Use internal function to verify backup statuses
+ struct Request req = { .server = &server };
+ struct GetClientResult *result = malloc(sizeof(struct GetClientResult) + sizeof(struct BackupRecord) * MAX_BACKUPS_DISPLAY);
+ int ret = get_client_with_backups(&req, 1, result);
+ assert(ret == 0);
+ assert(result->found == 1);
+
+ // Should have at least 2 backups (the interrupted one and the running one)
+ // Note: There may be more from earlier tests
+ assert(result->backups_len >= 2);
+
+ // Backups are ordered by started DESC, so newest first
+ // The most recent backup should be RUNNING
+ assert(strcmp(result->backups[0].status, "RUNNING") == 0);
+ // The previous backup should be INTERRUPTED
+ assert(strcmp(result->backups[1].status, "INTERRUPTED") == 0);
+ // The interrupted backup should have a completed timestamp
+ assert(result->backups[1].completed[0] != '\0');
+
+ free(result);
+ }
+
web_join(&server);
}