diff --git a/README.md b/README.md index c845620..e618909 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ If you need these, use MinIO or AWS. ## Quick Start ```bash -zig build -Doptimize=ReleaseFast +zig build -Doptimize=ReleaseFast -Dacl-list="admin:minioadmin:minioadmin" ./zig-out/bin/zs3 ``` @@ -146,8 +146,6 @@ Edit `main.zig`: const ctx = S3Context{ .allocator = allocator, .data_dir = "data", - .access_key = "minioadmin", - .secret_key = "minioadmin", }; const address = net.Address.parseIp4("0.0.0.0", 9000) diff --git a/acl.zig b/acl.zig new file mode 100755 index 0000000..444c893 --- /dev/null +++ b/acl.zig @@ -0,0 +1,72 @@ +const std = @import("std"); + +pub const Role = enum { + Admin, + Reader, + Writer, + Unknown, +}; + +pub const Credential = struct { + access_key: []const u8, + secret_key: []const u8, + role: Role, +}; + +// Function to convert string to Role enum +pub fn stringToRole(role_str: []const u8) !Role { + if (std.mem.eql(u8, role_str, "admin")) { + return Role.Admin; + } else if (std.mem.eql(u8, role_str, "reader")) { + return Role.Reader; + } else if (std.mem.eql(u8, role_str, "writer")) { + return Role.Writer; + } else { + return error.BadCredentialRole; + } +} + +// Function to parse a single credential string: "role:access_key:secret_key" +pub fn parseCredential(cred_str: []const u8) !Credential { + var itr = std.mem.splitScalar(u8, cred_str, ':'); + + const role_str = itr.next() orelse return error.BadCredentialFormat; + const access_key = itr.next() orelse return error.BadCredentialFormat; + const secret_key = itr.next() orelse return error.BadCredentialFormat; + + // Reject extra fields + if (itr.next() != null) return error.BadCredentialFormat; + + // Reject empty fields + if (role_str.len == 0 or access_key.len == 0 or secret_key.len == 0) + return error.BadCredentialFormat; + + return Credential{ + .role = try stringToRole(role_str), + .access_key = access_key, + .secret_key = secret_key, + }; +} + +// Function to parse a list of credential strings +pub fn parseCredentials(allocator: std.mem.Allocator, input: []const u8) ![]Credential { + if (input.len == 0) { + return error.BadCredentialInputFormat; + } + + var credentials = std.ArrayListUnmanaged(Credential){}; + errdefer credentials.deinit(allocator); + + var itr = std.mem.splitScalar(u8, input, ','); + while (itr.next()) |record| { + if (record.len == 0) continue; + + const pc = try parseCredential(record); + + try credentials.append(allocator, pc); + } + + if (credentials.items.len == 0) return error.BadCredentialInputFormat; + + return credentials.toOwnedSlice(allocator); +} diff --git a/build.zig b/build.zig old mode 100644 new mode 100755 index 2a1f1b1..48125f5 --- a/build.zig +++ b/build.zig @@ -1,10 +1,17 @@ const std = @import("std"); +const acl = @import("acl.zig"); -pub fn build(b: *std.Build) void { +pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const strip = b.option(bool, "strip", "Strip debug symbols") orelse (optimize != .Debug); + const acl_list = b.option([]const u8, "acl-list", "Admin credentials") orelse "admin:minioadmin:minioadmin"; + _ = try acl.parseCredentials(b.allocator, acl_list); + + const options = b.addOptions(); + options.addOption([]const u8, "acl_list", acl_list); + const exe = b.addExecutable(.{ .name = "zs3", .root_module = b.createModule(.{ @@ -15,6 +22,8 @@ pub fn build(b: *std.Build) void { }), }); + exe.root_module.addOptions("build_options", options); + b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); diff --git a/main.zig b/main.zig old mode 100644 new mode 100755 index f2a0571..975bfcb --- a/main.zig +++ b/main.zig @@ -3,6 +3,17 @@ const net = std.net; const posix = std.posix; const Allocator = std.mem.Allocator; const builtin = @import("builtin"); +const build_options = @import("build_options"); +const acl = @import("acl.zig"); + +const HTTP_METHOD = enum { + GET, + POST, + PUT, + DELETE, + HEAD, + OPTIONS, +}; const MAX_HEADER_SIZE = 8 * 1024; const MAX_BODY_SIZE = 5 * 1024 * 1024 * 1024; @@ -968,12 +979,19 @@ pub fn main() !void { var bootstrap_peers: [10][]const u8 = undefined; var bootstrap_count: usize = 0; var port: u16 = 9000; + var data_dir: []const u8 = build_options.data_dir; + var raw_acl_list: []const u8 = build_options.acl_list; + var show_help: bool = false; var args = std.process.args(); _ = args.skip(); // Skip program name while (args.next()) |arg| { if (std.mem.eql(u8, arg, "--distributed") or std.mem.eql(u8, arg, "-d")) { distributed_enabled = true; + } else if (std.mem.startsWith(u8, arg, "--data-dir=")) { + data_dir = arg[11..]; + } else if (std.mem.startsWith(u8, arg, "--acl=")) { + raw_acl_list = arg[6..]; } else if (std.mem.startsWith(u8, arg, "--bootstrap=")) { const peers_str = arg[12..]; var it = std.mem.splitScalar(u8, peers_str, ','); @@ -986,28 +1004,48 @@ pub fn main() !void { } else if (std.mem.startsWith(u8, arg, "--port=")) { port = std.fmt.parseInt(u16, arg[7..], 10) catch 9000; } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - std.debug.print( - \\zs3 - Distributed S3-compatible storage - \\ - \\Usage: zs3 [OPTIONS] - \\ - \\Options: - \\ --distributed, -d Enable distributed mode (peer-to-peer) - \\ --bootstrap=PEERS Comma-separated bootstrap peer addresses - \\ --port=PORT HTTP port (default: 9000) - \\ --help, -h Show this help - \\ - \\Examples: - \\ zs3 # Standalone mode - \\ zs3 --distributed # Distributed, auto-discover via mDNS - \\ zs3 -d --bootstrap=10.0.0.1:9000 # Distributed with bootstrap peer - \\ - , .{}); - return; - } + show_help = true; + } + } + + if (show_help) { + std.debug.print( + \\zs3 - Distributed S3-compatible storage + \\ + \\Usage: zs3 [OPTIONS] + \\ + \\Options: + \\ --distributed, -d + \\ Enable distributed mode (peer-to-peer) + \\ + \\ --bootstrap=PEERS + \\ Comma-separated bootstrap peer addresses + \\ + \\ --port={d} + \\ HTTP port to listen on + \\ + \\ --data-dir={s} + \\ The directory to store bucket data under + \\ + \\ --acl={s} + \\ The credentials for access + \\ + \\ --help, -h + \\ Show this help + \\ + \\Examples: + \\ zs3 # Standalone mode + \\ zs3 --distributed # Distributed, auto-discover via mDNS + \\ zs3 -d --bootstrap=10.0.0.1:9000 # Distributed with bootstrap peer + \\ + , .{ port, data_dir, raw_acl_list }); + return; } - std.fs.cwd().makeDir("data") catch |err| switch (err) { + const access_control_list = try acl.parseCredentials(allocator, raw_acl_list); + defer allocator.free(access_control_list); + + std.fs.cwd().makeDir(data_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -1018,13 +1056,13 @@ pub fn main() !void { if (distributed_enabled) { // Generate or load node ID - const node_id = try getOrCreateNodeId(allocator, "data"); + const node_id = try getOrCreateNodeId(allocator, data_dir); const config = DistributedConfig{ .enabled = true, .node_id = node_id, .http_port = port, }; - dist_ctx = DistributedContext.init(allocator, "data", config); + dist_ctx = DistributedContext.init(allocator, data_dir, config); var id_hex: [40]u8 = undefined; bytesToHex(&node_id, &id_hex); @@ -1032,10 +1070,56 @@ pub fn main() !void { std.log.info("Known peers: {d}", .{dist_ctx.?.kademlia.peerCount()}); // Create .cas and .index directories - std.fs.cwd().makePath("data/.cas") catch {}; - std.fs.cwd().makePath("data/.index") catch {}; + const dot_cas_path = try std.fs.path.join(allocator, &[_][]const u8{ data_dir, ".cas" }); + defer allocator.free(dot_cas_path); + + std.fs.cwd().makeDir(dot_cas_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + return error.FailedDataDirDotCasCreation; + }, + }; + + const dot_index_path = try std.fs.path.join(allocator, &[_][]const u8{ data_dir, ".index" }); + defer allocator.free(dot_index_path); + + std.fs.cwd().makeDir(dot_index_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + return error.FailedDataDirDotIndexCreation; + }, + }; } + var access_control_map = std.StringHashMap(acl.Credential).init(allocator); + errdefer access_control_map.deinit(); + + for (access_control_list) |credential| { + if (credential.secret_key.len > 252) { // 252 due to k_secret_buf.len and prefix of `AWS4` + // Never log the secret_key itself... + std.log.err("ACL credential '{s}' has a secret key exceeding 252 bytes; skipping", .{credential.access_key}); + continue; + } + + if (credential.access_key.len > 252) { // 252 to match secret_key.len + std.log.err("ACL credential '{s}' has an access key exceeding 252 bytes; skipping", .{credential.access_key}); + continue; + } + + // The key is the slice contents, not the pointer address + access_control_map.put(credential.access_key, credential) catch |err| { + return err; + }; + } + + var ctx = S3Context{ + .allocator = allocator, + .data_dir = data_dir, + .access_control_map = access_control_map, + .distributed = if (dist_ctx != null) &dist_ctx.? else null, + }; + defer ctx.deinit(); + const address = net.Address.parseIp4("0.0.0.0", port) catch unreachable; var server = try address.listen(.{ .reuse_address = true }); defer server.deinit(); @@ -1046,14 +1130,6 @@ pub fn main() !void { std.log.info("S3 server listening on http://0.0.0.0:{d}", .{port}); } - var ctx = S3Context{ - .allocator = allocator, - .data_dir = "data", - .access_key = "minioadmin", - .secret_key = "minioadmin", - .distributed = if (dist_ctx != null) &dist_ctx.? else null, - }; - if (builtin.os.tag == .linux) { try eventLoopEpoll(allocator, &ctx, &server); } else if (builtin.os.tag == .macos) { @@ -1192,8 +1268,7 @@ fn eventLoopKqueue(allocator: Allocator, ctx: *const S3Context, server: *net.Ser const S3Context = struct { allocator: Allocator, data_dir: []const u8, - access_key: []const u8, - secret_key: []const u8, + access_control_map: std.StringHashMap(acl.Credential), distributed: ?*DistributedContext = null, // Optional distributed mode fn bucketPath(self: *const S3Context, allocator: Allocator, bucket: []const u8) ![]const u8 { @@ -1203,6 +1278,10 @@ const S3Context = struct { fn objectPath(self: *const S3Context, allocator: Allocator, bucket: []const u8, key: []const u8) ![]const u8 { return std.fs.path.join(allocator, &[_][]const u8{ self.data_dir, bucket, key }); } + + pub fn deinit(self: *S3Context) void { + self.access_control_map.deinit(); + } }; const Request = struct { @@ -1553,11 +1632,17 @@ fn route(ctx: *const S3Context, allocator: Allocator, req: *Request, res: *Respo return; } + const acl_ctx = SigV4.verify(ctx, req, allocator); + // S3 API requires authentication - if (!SigV4.verify(ctx, req, allocator)) { + if (!acl_ctx.authenticated) { sendError(res, 403, "AccessDenied", "Invalid credentials"); return; } + if (!acl_ctx.granted(req.method)) { + sendError(res, 403, "AccessDenied", "Insufficient permissions"); + return; + } var path_parts = std.mem.splitScalar(u8, path, '/'); const bucket = path_parts.next() orelse ""; @@ -1654,21 +1739,70 @@ pub const SigV4 = struct { signature: []const u8, }; - fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) bool { - const auth_header = req.header("authorization") orelse return false; - const x_amz_date = req.header("x-amz-date") orelse return false; + const ACLCtx = struct { + authenticated: bool, + role: acl.Role, + + fn granted(self: *const ACLCtx, method: []const u8) bool { + switch (self.role) { + acl.Role.Admin => { + return true; + }, + acl.Role.Reader => { + const http_method = std.meta.stringToEnum(HTTP_METHOD, method) orelse { + return false; + }; + + switch (http_method) { + .GET => return true, + .HEAD => return true, + .OPTIONS => return true, + else => return false, + } + }, + acl.Role.Writer => { + const http_method = std.meta.stringToEnum(HTTP_METHOD, method) orelse { + return false; + }; + + switch (http_method) { + .PUT => return true, + .POST => return true, + .HEAD => return true, + .OPTIONS => return true, + else => return false, + } + }, + acl.Role.Unknown => { + return false; + }, + } + } + }; + + fn verify(ctx: *const S3Context, req: *const Request, allocator: Allocator) ACLCtx { + var acl_ctx = ACLCtx{ + .authenticated = false, + .role = acl.Role.Unknown, + }; + + const auth_header = req.header("authorization") orelse return acl_ctx; + const x_amz_date = req.header("x-amz-date") orelse return acl_ctx; const x_amz_content_sha256 = req.header("x-amz-content-sha256") orelse "UNSIGNED-PAYLOAD"; - const parsed = parseAuthHeader(auth_header) orelse return false; + const parsed = parseAuthHeader(auth_header) orelse return acl_ctx; + const credential = ctx.access_control_map.get(parsed.access_key); - if (!std.mem.eql(u8, parsed.access_key, ctx.access_key)) return false; + if (credential == null) { + return acl_ctx; + } const canonical = buildCanonicalRequest( allocator, req, parsed.signed_headers, x_amz_content_sha256, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(canonical); const string_to_sign = buildStringToSign( @@ -1678,20 +1812,25 @@ pub const SigV4 = struct { parsed.region, parsed.service, canonical, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(string_to_sign); const calculated_sig = calculateSignature( allocator, - ctx.secret_key, + credential.?.secret_key, parsed.date, parsed.region, parsed.service, string_to_sign, - ) catch return false; + ) catch return acl_ctx; defer allocator.free(calculated_sig); - return std.mem.eql(u8, calculated_sig, parsed.signature); + if (std.mem.eql(u8, calculated_sig, parsed.signature)) { + acl_ctx.authenticated = true; + acl_ctx.role = credential.?.role; + } + + return acl_ctx; } pub fn parseAuthHeader(header: []const u8) ?ParsedAuth {