From 6450f905c3212ae7bb50dc6b014c42d90cc03d57 Mon Sep 17 00:00:00 2001 From: biondizzle Date: Fri, 6 Mar 2026 03:50:52 -0500 Subject: [PATCH] support basic auth and create deployment container --- .dockerignore | 0 Dockerfile | 62 ++++++++++++++++++++++++++++++++ Makefile | 57 ++++++++++++++++++++++-------- docker-compose.yaml | 14 ++++++++ dynamodb/types.odin | 6 ++++ main.odin | 86 +++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ffa9cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# Stage 1: Builder +FROM ubuntu:24.04 AS builder + +RUN apt-get update && apt-get install -y \ + build-essential \ + g++ \ + librocksdb-dev \ + libsnappy-dev \ + liblz4-dev \ + libzstd-dev \ + zlib1g-dev \ + libbz2-dev \ + curl \ + clang \ + && rm -rf /var/lib/apt/lists/* + +# Install Odin compiler +RUN curl -Lo /tmp/odin.tar.gz https://github.com/odin-lang/Odin/releases/download/dev-2026-02/odin-linux-amd64-dev-2026-02.tar.gz \ + && tar -xzf /tmp/odin.tar.gz -C /tmp \ + && mv /tmp/odin-linux-amd64-nightly+2026-02-04 /opt/odin \ + && ln -s /opt/odin/odin /usr/local/bin/odin \ + && chmod +x /opt/odin/odin \ + && odin version \ + && rm /tmp/odin.tar.gz + +WORKDIR /app +COPY . . + +# just use the make file +RUN make release + +# Stage 2: Runtime for deployment +FROM ubuntu:24.04 AS runtime + +# Only runtime shared libs +RUN apt-get update && apt-get install -y \ + librocksdb8.9 \ + libsnappy1v5 \ + liblz4-1 \ + libzstd1 \ + zlib1g \ + libbz2-1.0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# only need the compiled binary from the builder stage +COPY --from=builder /app/build/jormundb . + +RUN mkdir -p /data + +#USER jormun + +EXPOSE 8002 +VOLUME ["/data"] + +ENV JORMUN_HOST=0.0.0.0 +ENV JORMUN_PORT=8002 +ENV JORMUN_DATA_DIR=/data +ENV JORMUN_VERBOSE=0 + +ENTRYPOINT ["./jormundb"] \ No newline at end of file diff --git a/Makefile b/Makefile index db2beb6..90328ed 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build release run test clean fmt help install +.PHONY: all build release run test clean fmt help install container registry-login container-build container-push # Project configuration PROJECT_NAME := jormundb @@ -6,6 +6,16 @@ ODIN := odin BUILD_DIR := build SRC_DIR := . +# Container configuration +REGISTRY := atl.vultrcr.com/jormun +IMAGE_NAME := jormundb +IMAGE_TAG ?= latest +FULL_IMAGE := $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) + +# Registry credentials +CR_USER ?= $(error CR_USER is not set) +CR_PASS ?= $(error CR_PASS is not set) + # C++ shim (WAL replication helpers via RocksDB C++ API) SHIM_DIR := rocksdb_shim SHIM_LIB := $(BUILD_DIR)/libjormun_rocksdb_shim.a @@ -16,9 +26,6 @@ CXX := g++ AR := ar CXXFLAGS := -O2 -fPIC -std=c++20 $(INCLUDE_PATH) -# name of the docker compose file for the python tests -SDK_TEST_COMPOSE := docker-compose-python-sdk-test.yaml - # RocksDB and compression libraries ROCKSDB_LIBS := -lrocksdb -lstdc++ -lsnappy -llz4 -lzstd -lz -lbz2 @@ -40,7 +47,7 @@ RELEASE_FLAGS := -o:speed -disable-assert -no-bounds-check COMMON_FLAGS := -vet -strict-style # Linker flags -EXTRA_LINKER_FLAGS := $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS) +EXTRA_LINKER_FLAGS = $(LIB_PATH) $(SHIM_LIB) $(ROCKSDB_LIBS) # Runtime configuration PORT ?= 8002 @@ -55,22 +62,22 @@ YELLOW := \033[0;33m RED := \033[0;31m NC := \033[0m # No Color -$(SHIM_LIB): $(SHIM_SRCS) $(SHIM_HDRS) | $(BUILD_DIR) +# To this — use a sentinel file instead of the dir name +$(BUILD_DIR)/.dir: + @mkdir -p $(BUILD_DIR) + @touch $(BUILD_DIR)/.dir + +$(SHIM_LIB): $(SHIM_SRCS) $(SHIM_HDRS) | $(BUILD_DIR)/.dir @echo "$(BLUE)Building RocksDB C++ shim...$(NC)" $(CXX) $(CXXFLAGS) -c $(SHIM_SRCS) -o $(BUILD_DIR)/rocksdb_shim.o $(AR) rcs $(SHIM_LIB) $(BUILD_DIR)/rocksdb_shim.o @echo "$(GREEN)✓ Built: $(SHIM_LIB)$(NC)" -$(BUILD_DIR): - @mkdir -p $(BUILD_DIR) - # Default target all: build -# Build debug version -build: $(SHIM_LIB) +build: $(SHIM_LIB) | $(BUILD_DIR)/.dir @echo "$(BLUE)Building $(PROJECT_NAME) (debug)...$(NC)" - @mkdir -p $(BUILD_DIR) $(ODIN) build $(SRC_DIR) \ $(COMMON_FLAGS) \ $(DEBUG_FLAGS) \ @@ -78,10 +85,8 @@ build: $(SHIM_LIB) -extra-linker-flags:"$(EXTRA_LINKER_FLAGS)" @echo "$(GREEN)✓ Build complete: $(BUILD_DIR)/$(PROJECT_NAME)$(NC)" -# Build optimized release version -release: $(SHIM_LIB) +release: $(SHIM_LIB) | $(BUILD_DIR)/.dir @echo "$(BLUE)Building $(PROJECT_NAME) (release)...$(NC)" - @mkdir -p $(BUILD_DIR) $(ODIN) build $(SRC_DIR) \ $(COMMON_FLAGS) \ $(RELEASE_FLAGS) \ @@ -154,6 +159,28 @@ dev: clean build run quick: @$(MAKE) build run +# Login to container registry +registry-login: + @echo "$(BLUE)Logging into container registry...$(NC)" + @echo "$(CR_PASS)" | docker login https://$(REGISTRY) -u $(CR_USER) --password-stdin + @echo "$(GREEN)✓ Logged in to $(REGISTRY)$(NC)" + +# Build container image +container-build: + @echo "$(BLUE)Building container image $(FULL_IMAGE)...$(NC)" + docker build -t $(FULL_IMAGE) . + @echo "$(GREEN)✓ Built: $(FULL_IMAGE)$(NC)" + +# Push container image +container-push: + @echo "$(BLUE)Pushing $(FULL_IMAGE)...$(NC)" + docker push $(FULL_IMAGE) + @echo "$(GREEN)✓ Pushed: $(FULL_IMAGE)$(NC)" + +# Login, build, and push in one shot +container: registry-login container-build container-push + @echo "$(GREEN)✓ Container $(FULL_IMAGE) built and pushed$(NC)" + # Show help help: @echo "$(BLUE)JormunDB - DynamoDB-compatible database$(NC)" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..32d875e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + jormundb: + image: atl.vultrcr.com/jormun/jormundb:latest + pull_policy: always + restart: unless-stopped + network_mode: host + volumes: + - ./data:/data + environment: + JORMUN_HOST: 0.0.0.0 + JORMUN_PORT: 8002 + JORMUN_DATA_DIR: /data + JORMUN_VERBOSE: 0 + JORMUN_ACCESS_KEY: AKIAIOSFODNN7EXAMPLE \ No newline at end of file diff --git a/dynamodb/types.odin b/dynamodb/types.odin index 5027d2c..f0e0c14 100644 --- a/dynamodb/types.odin +++ b/dynamodb/types.odin @@ -334,6 +334,8 @@ DynamoDB_Error_Type :: enum { ItemCollectionSizeLimitExceededException, InternalServerError, SerializationException, + MissingAuthenticationTokenException, // HTTP 403 + UnrecognizedClientException, // HTTP 400 Dynamo uses 400 for some invalid auth stuff } error_to_response :: proc(err_type: DynamoDB_Error_Type, message: string) -> string { @@ -356,6 +358,10 @@ error_to_response :: proc(err_type: DynamoDB_Error_Type, message: string) -> str type_str = "com.amazonaws.dynamodb.v20120810#InternalServerError" case .SerializationException: type_str = "com.amazonaws.dynamodb.v20120810#SerializationException" + case .MissingAuthenticationTokenException: + type_str = "com.amazonaws.dynamodb.v20120810#MissingAuthenticationTokenException" + case .UnrecognizedClientException: + type_str = "com.amazonaws.dynamodb.v20120810#UnrecognizedClientException" } return fmt.aprintf(`{{"__type":"%s","message":"%s"}}`, type_str, message) diff --git a/main.odin b/main.odin index 3d4fbe5..ceb08e1 100644 --- a/main.odin +++ b/main.odin @@ -13,6 +13,7 @@ Config :: struct { port: int, data_dir: string, verbose: bool, + access_key: string, // HTTP server config max_body_size: int, @@ -22,6 +23,12 @@ Config :: struct { max_requests_per_connection: int, } +// wraprer to pass both engine and access_key to handler +Handler_Context :: struct { + engine: ^dynamodb.Storage_Engine, + access_key: string, // Empty string means no auth required +} + main :: proc() { // Parse configuration config := parse_config() @@ -52,12 +59,17 @@ main :: proc() { max_requests_per_connection = config.max_requests_per_connection, } + // Create handler context + handler_ctx := new(Handler_Context, context.allocator) + handler_ctx.engine = engine + handler_ctx.access_key = config.access_key // Copy the string (its just a slice header) + server, server_ok := server_init( context.allocator, config.host, config.port, handle_dynamodb_request, - engine, + handler_ctx, server_config, ) @@ -76,15 +88,74 @@ main :: proc() { } } +// Extract Access Key ID from AWS sig4 Auth header +// Format is something like .... AWS4-HMAC-SHA256 Credential=/20230101/region/dynamodb/aws4_request, ... whatever +parse_access_key_from_auth :: proc(auth_header: string) -> (access_key: string, ok: bool) { + // Must start with AWS4-HMAC-SHA256 + if !strings.has_prefix(auth_header, "AWS4-HMAC-SHA256") { + return "", false + } + + // Find Credential= (case-sensitive per AWS spec) + cred_idx := strings.index(auth_header, "Credential=") + if cred_idx == -1 { + return "", false + } + + // Move past "Credential=" + start := cred_idx + len("Credential=") + if start >= len(auth_header) { + return "", false + } + + // Find end of access key (first '/' or ',' or space) + end := start + for end < len(auth_header) { + c := auth_header[end] + if c == '/' || c == ',' || c == ' ' { + break + } + end += 1 + } + + if end == start { + return "", false + } + + return auth_header[start:end], true +} + // DynamoDB request handler - called for each HTTP request with request-scoped arena allocator -handle_dynamodb_request :: proc(ctx: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response { - engine := cast(^dynamodb.Storage_Engine)ctx +handle_dynamodb_request :: proc(ctx_raw: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response { + // Snag the engine from the ctx wrapper + ctx := cast(^Handler_Context)ctx_raw + engine := ctx.engine // All allocations in this function use the request arena automatically response := response_init(request_alloc) response_add_header(&response, "Content-Type", "application/x-amz-json-1.0") response_add_header(&response, "x-amzn-RequestId", "local-request-id") + // AUTH CHECK! + // This is just a simple string match for speed, if we actually need to handle full blown keys, we should probably make an internal table that stores that like mysql or something + if ctx.access_key != "" { + auth_header := request_get_header(request, "Authorization") + + if auth_header == nil { + // No auth header provided but required + return make_error_response(&response, .MissingAuthenticationTokenException, + "Request is missing Authentication Token") + } + + provided_key, parse_ok := parse_access_key_from_auth(auth_header.?) + if !parse_ok || provided_key != ctx.access_key { + // Wrong key or malformed header + return make_error_response(&response, .UnrecognizedClientException, + "The security token included in the request is invalid") + } + } + + // Get X-Amz-Target header to determine operation target := request_get_header(request, "X-Amz-Target") if target == nil { @@ -2045,6 +2116,10 @@ make_error_response :: proc(response: ^HTTP_Response, err_type: dynamodb.DynamoD #partial switch err_type { case .InternalServerError: status = .Internal_Server_Error + case .MissingAuthenticationTokenException: + status = .Forbidden // 403 + case .UnrecognizedClientException: + status = .Bad_Request // 400 (dynamo uses 400 for this i guess because it's more of a malform than bad auth) case: status = .Bad_Request } @@ -2071,6 +2146,7 @@ parse_config :: proc() -> Config { read_buffer_size = 8 * 1024, // 8 KB enable_keep_alive = true, max_requests_per_connection = 1000, + access_key = "", // no auth required unless set via env var } // Environment variables (lower priority) @@ -2098,6 +2174,10 @@ parse_config :: proc() -> Config { } } + if access_key, ok := os.lookup_env("JORMUN_ACCESS_KEY"); ok { + config.access_key = access_key + } + // Command line arguments (highest priority) args := os.args[1:] // Skip program name