Refactor complex ObjectStore methods
This commit is contained in:
1 parent
b6b8e0adfb
commit
2276fee60c
9 files changed
+693
-500
No files matched your search
@@ -78,40 +78,14 @@ public final class ClusterNode implements AutoCloseable {
|
||||
respond(exchange, 200, "ok");
|
||||
return;
|
||||
}
|
||||
byte[] supplied = exchange.getRequestHeaders().getFirst("X-Cluster-Token") == null
|
||||
? new byte[0] : exchange.getRequestHeaders().getFirst("X-Cluster-Token")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(token, supplied)) {
|
||||
respond(exchange, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
if (!authorized(exchange)) return;
|
||||
if (path.equals("/identity") && exchange.getRequestMethod().equals("GET")) {
|
||||
respond(exchange, 200, identity.nodeId() + " " + identity.hostId());
|
||||
return;
|
||||
}
|
||||
if (!identity.nodeId().toString().equals(exchange.getRequestHeaders().getFirst("X-Cluster-Expected-Node"))) {
|
||||
respond(exchange, 409, "Wrong storage node");
|
||||
return;
|
||||
}
|
||||
if (exchange.getRequestMethod().equals("PUT") &&
|
||||
"true".equals(exchange.getRequestHeaders().getFirst("X-Cluster-Repair"))) {
|
||||
String suppliedRepair = exchange.getRequestHeaders().getFirst("X-Cluster-Repair-Token");
|
||||
byte[] suppliedBytes = suppliedRepair == null ? new byte[0]
|
||||
: suppliedRepair.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(repairToken, suppliedBytes)) {
|
||||
respond(exchange, 403, "Repair authority required");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!path.matches("/segments/[0-9a-f-]{36}")) {
|
||||
respond(exchange, 404, "Not found");
|
||||
return;
|
||||
}
|
||||
String id = path.substring("/segments/".length());
|
||||
if (!UUID.fromString(id).toString().equals(id)) {
|
||||
respond(exchange, 400, "Invalid segment ID");
|
||||
return;
|
||||
}
|
||||
if (!expectedNodeAndRepairAuthorized(exchange)) return;
|
||||
String id = segmentId(exchange, path);
|
||||
if (id == null) return;
|
||||
switch (exchange.getRequestMethod()) {
|
||||
case "PUT" -> put(exchange, segmentPath(id, true));
|
||||
case "GET" -> get(exchange, segmentPath(id, false));
|
||||
@@ -127,6 +101,48 @@ public final class ClusterNode implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authorized(HttpExchange exchange) throws IOException {
|
||||
byte[] supplied = exchange.getRequestHeaders().getFirst("X-Cluster-Token") == null
|
||||
? new byte[0] : exchange.getRequestHeaders().getFirst("X-Cluster-Token")
|
||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(token, supplied)) {
|
||||
respond(exchange, 403, "Forbidden");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean expectedNodeAndRepairAuthorized(HttpExchange exchange) throws IOException {
|
||||
if (!identity.nodeId().toString().equals(exchange.getRequestHeaders().getFirst("X-Cluster-Expected-Node"))) {
|
||||
respond(exchange, 409, "Wrong storage node");
|
||||
return false;
|
||||
}
|
||||
if (exchange.getRequestMethod().equals("PUT") &&
|
||||
"true".equals(exchange.getRequestHeaders().getFirst("X-Cluster-Repair"))) {
|
||||
String suppliedRepair = exchange.getRequestHeaders().getFirst("X-Cluster-Repair-Token");
|
||||
byte[] suppliedBytes = suppliedRepair == null ? new byte[0]
|
||||
: suppliedRepair.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(repairToken, suppliedBytes)) {
|
||||
respond(exchange, 403, "Repair authority required");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String segmentId(HttpExchange exchange, String path) throws IOException {
|
||||
if (!path.matches("/segments/[0-9a-f-]{36}")) {
|
||||
respond(exchange, 404, "Not found");
|
||||
return null;
|
||||
}
|
||||
String id = path.substring("/segments/".length());
|
||||
if (!UUID.fromString(id).toString().equals(id)) {
|
||||
respond(exchange, 400, "Invalid segment ID");
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private synchronized Path segmentPath(String id, boolean createShard) throws IOException {
|
||||
Path shard = segments.resolve(id.substring(0, 2));
|
||||
if (createShard && !Files.isDirectory(shard)) {
|
||||
|
||||
@@ -52,6 +52,23 @@ final class ClusterStore implements ObjectStorage {
|
||||
|
||||
@Override public Metadata put(String bucket, String key, InputStream input, long length, String expectedHash,
|
||||
String checksum, boolean createOnly, String contentType) throws IOException {
|
||||
validatePut(bucket, length, contentType);
|
||||
MessageDigest md5 = digest("MD5");
|
||||
Path staged = Files.createTempFile("objectstore-cluster-", ".pending");
|
||||
List<Segment> segments;
|
||||
byte[] fullHash;
|
||||
try {
|
||||
fullHash = stageInput(staged, input, length, expectedHash, checksum, md5);
|
||||
checkCapacity(bucket, key, length, createOnly);
|
||||
segments = uploadSegments(staged, length);
|
||||
} finally { Files.deleteIfExists(staged); }
|
||||
Metadata metadata = new Metadata(length, Instant.now().toEpochMilli(),
|
||||
HexFormat.of().formatHex(md5.digest()), fullHash, bucket, key, contentType);
|
||||
persistObject(metadata, segments, createOnly);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private void validatePut(String bucket, long length, String contentType) {
|
||||
if (!configuredBucket.equals(bucket)) throw new StoreException(404, "NoSuchBucket", "Bucket not found");
|
||||
if (length < 0) throw new StoreException(411, "MissingContentLength", "Content-Length is required");
|
||||
if (length > maxObject) throw new StoreException(413, "EntityTooLarge", "Object exceeds the configured size limit");
|
||||
@@ -59,74 +76,85 @@ final class ClusterStore implements ObjectStorage {
|
||||
throw new StoreException(503, "SlowDown", "Fewer than two storage hosts are available");
|
||||
if (contentType.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 255)
|
||||
throw new StoreException(400, "InvalidArgument", "Content-Type is too long");
|
||||
MessageDigest sha = digest("SHA-256"), md5 = digest("MD5");
|
||||
}
|
||||
|
||||
private byte[] stageInput(Path staged, InputStream input, long length, String expectedHash,
|
||||
String checksum, MessageDigest md5) throws IOException {
|
||||
MessageDigest sha = digest("SHA-256");
|
||||
try (OutputStream output = Files.newOutputStream(staged)) {
|
||||
byte[] buffer = new byte[65536];
|
||||
long remaining = length;
|
||||
while (remaining > 0) {
|
||||
int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
|
||||
if (count < 0) throw new StoreException(400, "IncompleteBody", "Payload length does not match Content-Length");
|
||||
if (count == 0) continue;
|
||||
sha.update(buffer, 0, count); md5.update(buffer, 0, count);
|
||||
output.write(buffer, 0, count);
|
||||
remaining -= count;
|
||||
}
|
||||
}
|
||||
if (input.read() != -1) throw new StoreException(413, "EntityTooLarge", "Payload exceeds declared size");
|
||||
byte[] fullHash = sha.digest();
|
||||
if (!HexFormat.of().formatHex(fullHash).equals(expectedHash))
|
||||
throw new StoreException(400, "XAmzContentSHA256Mismatch", "Payload hash mismatch");
|
||||
if (checksum != null && !Base64.getEncoder().encodeToString(fullHash).equals(checksum))
|
||||
throw new StoreException(400, "BadDigest", "SHA-256 checksum mismatch");
|
||||
return fullHash;
|
||||
}
|
||||
|
||||
private void checkCapacity(String bucket, String key, long length, boolean createOnly) throws IOException {
|
||||
try (Connection connection = connect()) {
|
||||
long previous = currentLength(connection, bucket, key);
|
||||
if (createOnly && previous >= 0)
|
||||
throw new StoreException(412, "PreconditionFailed", "Object already exists");
|
||||
try (PreparedStatement query = connection.prepareStatement("SELECT used_bytes FROM cluster_usage WHERE bucket=?")) {
|
||||
query.setString(1, bucket);
|
||||
try (ResultSet result = query.executeQuery()) {
|
||||
if (!result.next()) throw new SQLException("Bucket quota row is missing");
|
||||
if (result.getLong(1) - Math.max(0, previous) > maxTotal - length)
|
||||
throw new StoreException(507, "InsufficientStorage", "Store capacity limit reached");
|
||||
}
|
||||
}
|
||||
} catch (SQLException error) { throw databaseError(error); }
|
||||
}
|
||||
|
||||
private List<Segment> uploadSegments(Path staged, long length) throws IOException {
|
||||
List<Segment> segments = new ArrayList<>();
|
||||
byte[] fullHash;
|
||||
Path staged = Files.createTempFile("objectstore-cluster-", ".pending");
|
||||
try {
|
||||
try (OutputStream output = Files.newOutputStream(staged)) {
|
||||
byte[] buffer = new byte[65536];
|
||||
long remaining = length;
|
||||
while (remaining > 0) {
|
||||
int count = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
|
||||
if (count < 0) throw new StoreException(400, "IncompleteBody", "Payload length does not match Content-Length");
|
||||
if (count == 0) continue;
|
||||
sha.update(buffer, 0, count); md5.update(buffer, 0, count);
|
||||
output.write(buffer, 0, count);
|
||||
remaining -= count;
|
||||
}
|
||||
}
|
||||
if (input.read() != -1) throw new StoreException(413, "EntityTooLarge", "Payload exceeds declared size");
|
||||
fullHash = sha.digest();
|
||||
if (!HexFormat.of().formatHex(fullHash).equals(expectedHash))
|
||||
throw new StoreException(400, "XAmzContentSHA256Mismatch", "Payload hash mismatch");
|
||||
if (checksum != null && !Base64.getEncoder().encodeToString(fullHash).equals(checksum))
|
||||
throw new StoreException(400, "BadDigest", "SHA-256 checksum mismatch");
|
||||
try (Connection connection = connect()) {
|
||||
long previous = currentLength(connection, bucket, key);
|
||||
if (createOnly && previous >= 0)
|
||||
throw new StoreException(412, "PreconditionFailed", "Object already exists");
|
||||
try (PreparedStatement query = connection.prepareStatement("SELECT used_bytes FROM cluster_usage WHERE bucket=?")) {
|
||||
query.setString(1, bucket);
|
||||
try (ResultSet result = query.executeQuery()) {
|
||||
if (!result.next()) throw new SQLException("Bucket quota row is missing");
|
||||
if (result.getLong(1) - Math.max(0, previous) > maxTotal - length)
|
||||
throw new StoreException(507, "InsufficientStorage", "Store capacity limit reached");
|
||||
try (InputStream stagedInput = Files.newInputStream(staged)) {
|
||||
long remaining = length;
|
||||
while (remaining > 0) {
|
||||
int wanted = (int) Math.min(ClusterNode.MAX_SEGMENT, remaining);
|
||||
byte[] bytes = stagedInput.readNBytes(wanted);
|
||||
if (bytes.length != wanted) throw new IOException("Staged object was truncated");
|
||||
byte[] segmentHash = SigV4.hash(bytes);
|
||||
UUID id = UUID.randomUUID();
|
||||
List<UUID> replicas = new ArrayList<>();
|
||||
Set<UUID> acceptedHosts = new HashSet<>();
|
||||
for (int index : PlacementPolicy.candidates(id, nodes, testNodeDomains)) {
|
||||
UUID host = nodes.faultDomain(index, testNodeDomains);
|
||||
if (acceptedHosts.contains(host)) continue;
|
||||
try {
|
||||
nodes.put(index, id, bytes, segmentHash);
|
||||
replicas.add(nodes.node(index).id());
|
||||
acceptedHosts.add(host);
|
||||
if (acceptedHosts.size() == 3) break;
|
||||
} catch (IOException error) {
|
||||
System.err.println("Cluster node " + nodes.node(index).id() +
|
||||
" did not accept segment " + id + ": " + error.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (SQLException error) { throw databaseError(error); }
|
||||
try (InputStream stagedInput = Files.newInputStream(staged)) {
|
||||
long remaining = length;
|
||||
while (remaining > 0) {
|
||||
int wanted = (int) Math.min(ClusterNode.MAX_SEGMENT, remaining);
|
||||
byte[] bytes = stagedInput.readNBytes(wanted);
|
||||
if (bytes.length != wanted) throw new IOException("Staged object was truncated");
|
||||
byte[] segmentHash = SigV4.hash(bytes);
|
||||
UUID id = UUID.randomUUID();
|
||||
List<UUID> replicas = new ArrayList<>();
|
||||
Set<UUID> acceptedHosts = new HashSet<>();
|
||||
for (int index : PlacementPolicy.candidates(id, nodes, testNodeDomains)) {
|
||||
UUID host = nodes.faultDomain(index, testNodeDomains);
|
||||
if (acceptedHosts.contains(host)) continue;
|
||||
try {
|
||||
nodes.put(index, id, bytes, segmentHash);
|
||||
replicas.add(nodes.node(index).id());
|
||||
acceptedHosts.add(host);
|
||||
if (acceptedHosts.size() == 3) break;
|
||||
} catch (IOException error) {
|
||||
System.err.println("Cluster node " + nodes.node(index).id() +
|
||||
" did not accept segment " + id + ": " + error.getMessage());
|
||||
}
|
||||
}
|
||||
if (acceptedHosts.size() < 2)
|
||||
throw new StoreException(503, "SlowDown", "Fewer than two storage hosts accepted the segment");
|
||||
segments.add(new Segment(id, wanted, segmentHash, List.copyOf(replicas)));
|
||||
remaining -= wanted;
|
||||
}
|
||||
if (acceptedHosts.size() < 2)
|
||||
throw new StoreException(503, "SlowDown", "Fewer than two storage hosts accepted the segment");
|
||||
segments.add(new Segment(id, wanted, segmentHash, List.copyOf(replicas)));
|
||||
remaining -= wanted;
|
||||
}
|
||||
} finally { Files.deleteIfExists(staged); }
|
||||
Metadata metadata = new Metadata(length, Instant.now().toEpochMilli(),
|
||||
HexFormat.of().formatHex(md5.digest()), fullHash, bucket, key, contentType);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
private void persistObject(Metadata metadata, List<Segment> segments, boolean createOnly) throws IOException {
|
||||
String bucket = metadata.bucket(), key = metadata.key();
|
||||
long length = metadata.length();
|
||||
UUID generation = UUID.randomUUID();
|
||||
try (Connection connection = connect()) {
|
||||
connection.setAutoCommit(false);
|
||||
@@ -159,7 +187,6 @@ final class ClusterStore implements ObjectStorage {
|
||||
delete.setString(1, bucket); delete.setString(2, key); delete.executeUpdate();
|
||||
}
|
||||
connection.commit();
|
||||
return metadata;
|
||||
} catch (SQLException | RuntimeException error) {
|
||||
connection.rollback();
|
||||
if (error instanceof SQLException sql) throw databaseError(sql);
|
||||
@@ -241,45 +268,54 @@ final class ClusterStore implements ObjectStorage {
|
||||
}
|
||||
|
||||
@Override public ListPage list(String bucket, String prefix, String delimiter, int maxKeys, String after) throws IOException {
|
||||
List<ListedObject> entries = new ArrayList<>();
|
||||
List<String> prefixes = new ArrayList<>();
|
||||
if (maxKeys == 0) return new ListPage(entries, prefixes, null, false);
|
||||
String lastKey = null, activePrefix = null;
|
||||
boolean truncated = false;
|
||||
if (maxKeys == 0) return new ListPage(new ArrayList<>(), new ArrayList<>(), null, false);
|
||||
try (Connection connection = connect()) {
|
||||
connection.setAutoCommit(false);
|
||||
ListPage page;
|
||||
try (PreparedStatement query = connection.prepareStatement(
|
||||
"SELECT object_key, length, modified, etag, sha256, content_type FROM cluster_objects WHERE bucket=? AND object_key>=? ORDER BY object_key")) {
|
||||
query.setString(1, bucket);
|
||||
query.setString(2, after != null && after.compareTo(prefix) > 0 ? after : prefix);
|
||||
query.setFetchSize(128);
|
||||
try (ResultSet result = query.executeQuery()) {
|
||||
while (result.next()) {
|
||||
String key = result.getString(1);
|
||||
if (!key.startsWith(prefix)) break;
|
||||
if (after != null && key.compareTo(after) <= 0) continue;
|
||||
String group = null;
|
||||
if (!delimiter.isEmpty()) {
|
||||
int at = key.indexOf(delimiter, prefix.length());
|
||||
if (at >= 0) group = key.substring(0, at + delimiter.length());
|
||||
}
|
||||
if (group != null && group.equals(activePrefix)) { lastKey = key; continue; }
|
||||
if (entries.size() + prefixes.size() >= maxKeys) { truncated = true; break; }
|
||||
if (group != null) { prefixes.add(group); activePrefix = group; }
|
||||
else {
|
||||
entries.add(new ListedObject(key, new Metadata(result.getLong(2), result.getLong(3),
|
||||
result.getString(4), result.getBytes(5), bucket, key, result.getString(6))));
|
||||
activePrefix = null;
|
||||
}
|
||||
lastKey = key;
|
||||
}
|
||||
page = readListPage(result, bucket, prefix, delimiter, maxKeys, after);
|
||||
}
|
||||
}
|
||||
connection.commit();
|
||||
return page;
|
||||
} catch (SQLException error) { throw databaseError(error); }
|
||||
}
|
||||
|
||||
private static ListPage readListPage(ResultSet result, String bucket, String prefix, String delimiter,
|
||||
int maxKeys, String after) throws SQLException {
|
||||
List<ListedObject> entries = new ArrayList<>();
|
||||
List<String> prefixes = new ArrayList<>();
|
||||
String lastKey = null, activePrefix = null;
|
||||
boolean truncated = false;
|
||||
while (result.next()) {
|
||||
String key = result.getString(1);
|
||||
if (!key.startsWith(prefix)) break;
|
||||
if (after != null && key.compareTo(after) <= 0) continue;
|
||||
String group = commonPrefix(key, prefix, delimiter);
|
||||
if (group != null && group.equals(activePrefix)) { lastKey = key; continue; }
|
||||
if (entries.size() + prefixes.size() >= maxKeys) { truncated = true; break; }
|
||||
if (group != null) { prefixes.add(group); activePrefix = group; }
|
||||
else {
|
||||
entries.add(new ListedObject(key, new Metadata(result.getLong(2), result.getLong(3),
|
||||
result.getString(4), result.getBytes(5), bucket, key, result.getString(6))));
|
||||
activePrefix = null;
|
||||
}
|
||||
lastKey = key;
|
||||
}
|
||||
return new ListPage(entries, prefixes, truncated ? lastKey : null, truncated);
|
||||
}
|
||||
|
||||
private static String commonPrefix(String key, String prefix, String delimiter) {
|
||||
if (delimiter.isEmpty()) return null;
|
||||
int at = key.indexOf(delimiter, prefix.length());
|
||||
return at < 0 ? null : key.substring(0, at + delimiter.length());
|
||||
}
|
||||
|
||||
private long lockUsage(Connection connection, String bucket) throws SQLException {
|
||||
try (PreparedStatement query = connection.prepareStatement("SELECT used_bytes FROM cluster_usage WHERE bucket=? FOR UPDATE")) {
|
||||
query.setString(1, bucket);
|
||||
|
||||
@@ -117,67 +117,82 @@ final class DiskStore implements ObjectStorage {
|
||||
byte[] bucketBytes = bucket.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] typeBytes = contentType.getBytes(StandardCharsets.UTF_8);
|
||||
if (bucketBytes.length > 63 || keyBytes.length > 1024 || typeBytes.length > 255)
|
||||
throw new StoreException(400, "InvalidArgument", "Object metadata is too long");
|
||||
int headerLength = HEADER_V2 + bucketBytes.length + keyBytes.length + typeBytes.length;
|
||||
validateMetadataLengths(bucketBytes, keyBytes, typeBytes);
|
||||
Path destination = object(bucket, key), pending = Files.createTempFile(temporary, "upload-", ".part");
|
||||
try {
|
||||
MessageDigest sha = digest("SHA-256"), md5 = digest("MD5");
|
||||
long count = 0;
|
||||
try (OutputStream out = Files.newOutputStream(pending)) {
|
||||
out.write(new byte[headerLength]);
|
||||
byte[] buffer = new byte[65536]; int n;
|
||||
while ((n = input.read(buffer)) != -1) {
|
||||
count += n;
|
||||
if (count > length || count > maxObject)
|
||||
throw new StoreException(413, "EntityTooLarge", "Payload exceeds declared size");
|
||||
sha.update(buffer, 0, n); md5.update(buffer, 0, n); out.write(buffer, 0, n);
|
||||
}
|
||||
}
|
||||
if (count != length) throw new StoreException(400, "IncompleteBody", "Payload length does not match Content-Length");
|
||||
byte[] hash = sha.digest(), etag = md5.digest();
|
||||
if (!MessageDigest.isEqual(hash, HexFormat.of().parseHex(expectedHash)))
|
||||
throw new StoreException(400, "XAmzContentSHA256Mismatch", "Payload hash mismatch");
|
||||
if (checksum != null && !Base64.getEncoder().encodeToString(hash).equals(checksum))
|
||||
throw new StoreException(400, "BadDigest", "SHA-256 checksum mismatch");
|
||||
long modified = Instant.now().toEpochMilli();
|
||||
ByteBuffer header = ByteBuffer.allocate(headerLength).putLong(MAGIC_V2).putLong(count)
|
||||
.putLong(modified).put(etag).put(hash).putShort((short) bucketBytes.length)
|
||||
.putShort((short) keyBytes.length).putShort((short) typeBytes.length)
|
||||
.put(bucketBytes).put(keyBytes).put(typeBytes);
|
||||
header.flip();
|
||||
try (FileChannel file = FileChannel.open(pending, StandardOpenOption.WRITE)) {
|
||||
while (header.hasRemaining()) file.write(header, header.position());
|
||||
file.force(true);
|
||||
}
|
||||
Metadata metadata = new Metadata(count, modified, SigV4.hex(etag), hash, bucket, key, contentType);
|
||||
synchronized (lock(destination)) {
|
||||
long previous = 0;
|
||||
boolean existed = Files.exists(destination);
|
||||
boolean legacy = false;
|
||||
if (existed) {
|
||||
if (createOnly) throw new StoreException(412, "PreconditionFailed", "Object already exists");
|
||||
try (var in = new DataInputStream(Files.newInputStream(destination))) {
|
||||
Metadata old = readRecord(in).metadata();
|
||||
previous = old.length();
|
||||
legacy = old.key() == null;
|
||||
}
|
||||
}
|
||||
synchronized (this) {
|
||||
if (used - previous + count > maxTotal)
|
||||
throw new StoreException(507, "InsufficientStorage", "Store capacity limit reached");
|
||||
Files.move(pending, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
used = used - previous + count;
|
||||
if (!existed) objectCount++;
|
||||
if (legacy) legacyCount--;
|
||||
index.put(indexKey(bucket, key), metadata);
|
||||
syncDirectory(destination.getParent());
|
||||
}
|
||||
}
|
||||
Metadata metadata = stagePut(pending, input, length, expectedHash, checksum,
|
||||
bucket, key, contentType, bucketBytes, keyBytes, typeBytes);
|
||||
installPending(destination, pending, metadata, createOnly);
|
||||
return metadata;
|
||||
} finally { Files.deleteIfExists(pending); }
|
||||
}
|
||||
|
||||
private static void validateMetadataLengths(byte[] bucketBytes, byte[] keyBytes, byte[] typeBytes) {
|
||||
if (bucketBytes.length > 63 || keyBytes.length > 1024 || typeBytes.length > 255)
|
||||
throw new StoreException(400, "InvalidArgument", "Object metadata is too long");
|
||||
}
|
||||
|
||||
private Metadata stagePut(Path pending, InputStream input, long length, String expectedHash, String checksum,
|
||||
String bucket, String key, String contentType,
|
||||
byte[] bucketBytes, byte[] keyBytes, byte[] typeBytes) throws IOException {
|
||||
int headerLength = HEADER_V2 + bucketBytes.length + keyBytes.length + typeBytes.length;
|
||||
MessageDigest sha = digest("SHA-256"), md5 = digest("MD5");
|
||||
long count = 0;
|
||||
try (OutputStream out = Files.newOutputStream(pending)) {
|
||||
out.write(new byte[headerLength]);
|
||||
byte[] buffer = new byte[65536]; int n;
|
||||
while ((n = input.read(buffer)) != -1) {
|
||||
count += n;
|
||||
if (count > length || count > maxObject)
|
||||
throw new StoreException(413, "EntityTooLarge", "Payload exceeds declared size");
|
||||
sha.update(buffer, 0, n); md5.update(buffer, 0, n); out.write(buffer, 0, n);
|
||||
}
|
||||
}
|
||||
if (count != length) throw new StoreException(400, "IncompleteBody", "Payload length does not match Content-Length");
|
||||
byte[] hash = sha.digest(), etag = md5.digest();
|
||||
if (!MessageDigest.isEqual(hash, HexFormat.of().parseHex(expectedHash)))
|
||||
throw new StoreException(400, "XAmzContentSHA256Mismatch", "Payload hash mismatch");
|
||||
if (checksum != null && !Base64.getEncoder().encodeToString(hash).equals(checksum))
|
||||
throw new StoreException(400, "BadDigest", "SHA-256 checksum mismatch");
|
||||
long modified = Instant.now().toEpochMilli();
|
||||
ByteBuffer header = ByteBuffer.allocate(headerLength).putLong(MAGIC_V2).putLong(count)
|
||||
.putLong(modified).put(etag).put(hash).putShort((short) bucketBytes.length)
|
||||
.putShort((short) keyBytes.length).putShort((short) typeBytes.length)
|
||||
.put(bucketBytes).put(keyBytes).put(typeBytes);
|
||||
header.flip();
|
||||
try (FileChannel file = FileChannel.open(pending, StandardOpenOption.WRITE)) {
|
||||
while (header.hasRemaining()) file.write(header, header.position());
|
||||
file.force(true);
|
||||
}
|
||||
return new Metadata(count, modified, SigV4.hex(etag), hash, bucket, key, contentType);
|
||||
}
|
||||
|
||||
private void installPending(Path destination, Path pending, Metadata metadata, boolean createOnly) throws IOException {
|
||||
synchronized (lock(destination)) {
|
||||
long previous = 0;
|
||||
boolean existed = Files.exists(destination);
|
||||
boolean legacy = false;
|
||||
if (existed) {
|
||||
if (createOnly) throw new StoreException(412, "PreconditionFailed", "Object already exists");
|
||||
try (var in = new DataInputStream(Files.newInputStream(destination))) {
|
||||
Metadata old = readRecord(in).metadata();
|
||||
previous = old.length();
|
||||
legacy = old.key() == null;
|
||||
}
|
||||
}
|
||||
synchronized (this) {
|
||||
if (used - previous + metadata.length() > maxTotal)
|
||||
throw new StoreException(507, "InsufficientStorage", "Store capacity limit reached");
|
||||
Files.move(pending, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
used = used - previous + metadata.length();
|
||||
if (!existed) objectCount++;
|
||||
if (legacy) legacyCount--;
|
||||
index.put(indexKey(metadata.bucket(), metadata.key()), metadata);
|
||||
syncDirectory(destination.getParent());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public OpenObject open(String bucket, String key) throws IOException {
|
||||
Path destination = object(bucket, key);
|
||||
synchronized (lock(destination)) {
|
||||
|
||||
@@ -45,87 +45,14 @@ public final class Main {
|
||||
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
|
||||
try {
|
||||
if (!admitted) throw new StoreException(503, "SlowDown", "Too many concurrent requests");
|
||||
if (exchange.getRequestURI().getRawPath().equals("/health") && exchange.getRequestMethod().equals("GET")) {
|
||||
byte[] body = "{\"status\":\"ok\",\"service\":\"lunarsky-objectstore\"}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
return;
|
||||
}
|
||||
if (exchange.getRequestURI().getRawPath().equals("/ready") && exchange.getRequestMethod().equals("GET")) {
|
||||
boolean ready = store.ready();
|
||||
byte[] body = (ready ? "ready" : "unavailable").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
|
||||
exchange.sendResponseHeaders(ready ? 200 : 503, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
return;
|
||||
}
|
||||
if (handleStatus(exchange)) return;
|
||||
String hash = authentication.verify(exchange.getRequestMethod(), exchange.getRequestURI(), exchange.getRequestHeaders());
|
||||
String path = SigV4.decode(exchange.getRequestURI().getRawPath());
|
||||
Map<String, String> query = query(exchange.getRequestURI().getRawQuery());
|
||||
if (path.equals("/" + bucket) || path.equals("/" + bucket + "/")) {
|
||||
if (!exchange.getRequestMethod().equals("GET") || !"2".equals(query.get("list-type")) ||
|
||||
!query.keySet().stream().allMatch(java.util.Set.of("list-type", "prefix", "delimiter", "max-keys",
|
||||
"continuation-token", "start-after", "encoding-type", "x-id")::contains) ||
|
||||
(query.containsKey("x-id") && !"ListObjectsV2".equals(query.get("x-id"))))
|
||||
unsupported("Bucket operation");
|
||||
requireEmptyBody(exchange, hash);
|
||||
listObjects(exchange, query);
|
||||
return;
|
||||
}
|
||||
String prefix = "/" + bucket + "/";
|
||||
if (!path.startsWith(prefix)) throw new StoreException(404, "NoSuchBucket", "Bucket not found");
|
||||
String key = path.substring(prefix.length());
|
||||
if (key.isEmpty() || key.getBytes(StandardCharsets.UTF_8).length > 1024 || key.indexOf('\0') >= 0)
|
||||
throw new StoreException(400, "InvalidArgument", "Invalid object key");
|
||||
String method = exchange.getRequestMethod();
|
||||
boolean multipartRequest = multipartRequest(method, query);
|
||||
if (!multipartRequest && !query.isEmpty() && !(query.size() == 1 &&
|
||||
("PutObject".equals(query.get("x-id")) || "GetObject".equals(query.get("x-id")) ||
|
||||
"HeadObject".equals(query.get("x-id")) || "DeleteObject".equals(query.get("x-id")))))
|
||||
unsupported("Query operation");
|
||||
var headers = exchange.getRequestHeaders();
|
||||
for (String name : headers.keySet()) {
|
||||
String lower = name.toLowerCase(java.util.Locale.ROOT);
|
||||
if (lower.startsWith("x-amz-") && !java.util.Set.of("x-amz-date", "x-amz-content-sha256",
|
||||
"x-amz-checksum-sha256", "x-amz-sdk-checksum-algorithm", "x-amz-user-agent").contains(lower))
|
||||
unsupported("Amazon header");
|
||||
if (lower.startsWith("x-amz-meta-") || lower.startsWith("x-amz-server-side-") ||
|
||||
lower.startsWith("x-amz-copy-") || lower.startsWith("x-amz-acl") ||
|
||||
lower.startsWith("x-amz-grant") || lower.startsWith("x-amz-tagging") ||
|
||||
lower.equals("content-md5")) unsupported("Object metadata, encryption, ACL, copy, tagging or MD5 header");
|
||||
if (lower.startsWith("x-amz-checksum-") && !lower.equals("x-amz-checksum-sha256"))
|
||||
unsupported("Checksum algorithm");
|
||||
}
|
||||
String algorithm = SigV4.single(headers, "x-amz-sdk-checksum-algorithm");
|
||||
if (algorithm != null && !algorithm.equals("SHA256")) unsupported("Checksum algorithm");
|
||||
if (multipartRequest) {
|
||||
handleMultipart(exchange, method, query, key, hash);
|
||||
return;
|
||||
}
|
||||
if (!method.equals("PUT")) requireEmptyBody(exchange, hash);
|
||||
switch (method) {
|
||||
case "PUT" -> {
|
||||
String length = SigV4.single(headers, "content-length"), condition = SigV4.single(headers, "if-none-match");
|
||||
if (condition != null && !condition.equals("*")) unsupported("Write condition");
|
||||
long bytes;
|
||||
try { bytes = length == null ? -1 : Long.parseLong(length); }
|
||||
catch (NumberFormatException e) { throw new StoreException(400, "InvalidArgument", "Invalid Content-Length"); }
|
||||
if (headers.containsKey("content-encoding")) unsupported("Encoded payload");
|
||||
String contentType = contentType(headers);
|
||||
ObjectStorage.Metadata data = store.put(bucket, key, exchange.getRequestBody(), bytes, hash,
|
||||
SigV4.single(headers, "x-amz-checksum-sha256"), condition != null, contentType);
|
||||
exchange.getResponseHeaders().set("ETag", "\"" + data.etag() + "\"");
|
||||
exchange.getResponseHeaders().set("x-amz-checksum-sha256", Base64.getEncoder().encodeToString(data.sha256()));
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
}
|
||||
case "GET", "HEAD" -> readObject(exchange, key);
|
||||
case "DELETE" -> {
|
||||
if (headers.containsKey("if-none-match")) unsupported("Conditional delete");
|
||||
store.delete(bucket, key);
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
default -> unsupported("HTTP method");
|
||||
handleBucket(exchange, query, hash);
|
||||
} else {
|
||||
handleObject(exchange, path, query, hash);
|
||||
}
|
||||
} catch (StoreException error) { sendError(exchange, error.status, error.code, error.getMessage(), requestId); }
|
||||
catch (Exception error) {
|
||||
@@ -134,6 +61,100 @@ public final class Main {
|
||||
} finally { if (admitted) slots.release(); exchange.close(); }
|
||||
}
|
||||
|
||||
private boolean handleStatus(HttpExchange exchange) throws IOException {
|
||||
if (!exchange.getRequestMethod().equals("GET")) return false;
|
||||
String path = exchange.getRequestURI().getRawPath();
|
||||
if (path.equals("/health")) {
|
||||
byte[] body = "{\"status\":\"ok\",\"service\":\"lunarsky-objectstore\"}".getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
return true;
|
||||
}
|
||||
if (!path.equals("/ready")) return false;
|
||||
boolean ready = store.ready();
|
||||
byte[] body = (ready ? "ready" : "unavailable").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
|
||||
exchange.sendResponseHeaders(ready ? 200 : 503, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleBucket(HttpExchange exchange, Map<String, String> query, String hash) throws IOException {
|
||||
if (!exchange.getRequestMethod().equals("GET") || !"2".equals(query.get("list-type")) ||
|
||||
!query.keySet().stream().allMatch(java.util.Set.of("list-type", "prefix", "delimiter", "max-keys",
|
||||
"continuation-token", "start-after", "encoding-type", "x-id")::contains) ||
|
||||
(query.containsKey("x-id") && !"ListObjectsV2".equals(query.get("x-id"))))
|
||||
unsupported("Bucket operation");
|
||||
requireEmptyBody(exchange, hash);
|
||||
listObjects(exchange, query);
|
||||
}
|
||||
|
||||
private void handleObject(HttpExchange exchange, String path, Map<String, String> query, String hash) throws IOException {
|
||||
String prefix = "/" + bucket + "/";
|
||||
if (!path.startsWith(prefix)) throw new StoreException(404, "NoSuchBucket", "Bucket not found");
|
||||
String key = path.substring(prefix.length());
|
||||
if (key.isEmpty() || key.getBytes(StandardCharsets.UTF_8).length > 1024 || key.indexOf('\0') >= 0)
|
||||
throw new StoreException(400, "InvalidArgument", "Invalid object key");
|
||||
String method = exchange.getRequestMethod();
|
||||
boolean multipartRequest = multipartRequest(method, query);
|
||||
if (!multipartRequest && !query.isEmpty() && !(query.size() == 1 &&
|
||||
("PutObject".equals(query.get("x-id")) || "GetObject".equals(query.get("x-id")) ||
|
||||
"HeadObject".equals(query.get("x-id")) || "DeleteObject".equals(query.get("x-id")))))
|
||||
unsupported("Query operation");
|
||||
validateObjectHeaders(exchange.getRequestHeaders());
|
||||
if (multipartRequest) {
|
||||
handleMultipart(exchange, method, query, key, hash);
|
||||
return;
|
||||
}
|
||||
if (!method.equals("PUT")) requireEmptyBody(exchange, hash);
|
||||
switch (method) {
|
||||
case "PUT" -> putObject(exchange, key, hash);
|
||||
case "GET", "HEAD" -> readObject(exchange, key);
|
||||
case "DELETE" -> deleteObject(exchange, key);
|
||||
default -> unsupported("HTTP method");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateObjectHeaders(com.sun.net.httpserver.Headers headers) {
|
||||
for (String name : headers.keySet()) {
|
||||
String lower = name.toLowerCase(java.util.Locale.ROOT);
|
||||
if (lower.startsWith("x-amz-") && !java.util.Set.of("x-amz-date", "x-amz-content-sha256",
|
||||
"x-amz-checksum-sha256", "x-amz-sdk-checksum-algorithm", "x-amz-user-agent").contains(lower))
|
||||
unsupported("Amazon header");
|
||||
if (lower.startsWith("x-amz-meta-") || lower.startsWith("x-amz-server-side-") ||
|
||||
lower.startsWith("x-amz-copy-") || lower.startsWith("x-amz-acl") ||
|
||||
lower.startsWith("x-amz-grant") || lower.startsWith("x-amz-tagging") ||
|
||||
lower.equals("content-md5")) unsupported("Object metadata, encryption, ACL, copy, tagging or MD5 header");
|
||||
if (lower.startsWith("x-amz-checksum-") && !lower.equals("x-amz-checksum-sha256"))
|
||||
unsupported("Checksum algorithm");
|
||||
}
|
||||
String algorithm = SigV4.single(headers, "x-amz-sdk-checksum-algorithm");
|
||||
if (algorithm != null && !algorithm.equals("SHA256")) unsupported("Checksum algorithm");
|
||||
}
|
||||
|
||||
private void putObject(HttpExchange exchange, String key, String hash) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String length = SigV4.single(headers, "content-length"), condition = SigV4.single(headers, "if-none-match");
|
||||
if (condition != null && !condition.equals("*")) unsupported("Write condition");
|
||||
long bytes;
|
||||
try { bytes = length == null ? -1 : Long.parseLong(length); }
|
||||
catch (NumberFormatException e) { throw new StoreException(400, "InvalidArgument", "Invalid Content-Length"); }
|
||||
if (headers.containsKey("content-encoding")) unsupported("Encoded payload");
|
||||
String type = contentType(headers);
|
||||
ObjectStorage.Metadata data = store.put(bucket, key, exchange.getRequestBody(), bytes, hash,
|
||||
SigV4.single(headers, "x-amz-checksum-sha256"), condition != null, type);
|
||||
exchange.getResponseHeaders().set("ETag", "\"" + data.etag() + "\"");
|
||||
exchange.getResponseHeaders().set("x-amz-checksum-sha256", Base64.getEncoder().encodeToString(data.sha256()));
|
||||
exchange.sendResponseHeaders(200, -1);
|
||||
}
|
||||
|
||||
private void deleteObject(HttpExchange exchange, String key) throws IOException {
|
||||
if (exchange.getRequestHeaders().containsKey("if-none-match")) unsupported("Conditional delete");
|
||||
store.delete(bucket, key);
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
}
|
||||
|
||||
private static String contentType(com.sun.net.httpserver.Headers headers) {
|
||||
String value = SigV4.single(headers, "content-type");
|
||||
if (value == null) return "application/octet-stream";
|
||||
@@ -337,6 +358,24 @@ public final class Main {
|
||||
}
|
||||
|
||||
private void listObjects(HttpExchange exchange, Map<String, String> query) throws IOException {
|
||||
ListRequest request = listRequest(query);
|
||||
var page = store.list(bucket, request.prefix(), request.delimiter(), request.maxKeys(), request.after());
|
||||
StringBuilder xml = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\"?><ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">");
|
||||
appendListHeader(xml, query, request, page);
|
||||
appendListEntries(xml, page, request.encoding());
|
||||
if (page.truncated()) xml.append("<NextContinuationToken>")
|
||||
.append(Base64.getUrlEncoder().withoutPadding().encodeToString(page.nextKey().getBytes(StandardCharsets.UTF_8)))
|
||||
.append("</NextContinuationToken>");
|
||||
xml.append("</ListBucketResult>");
|
||||
byte[] body = xml.toString().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/xml");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
}
|
||||
|
||||
private record ListRequest(String prefix, String delimiter, String encoding, int maxKeys, String after) { }
|
||||
|
||||
private static ListRequest listRequest(Map<String, String> query) {
|
||||
String prefix = query.getOrDefault("prefix", ""), delimiter = query.getOrDefault("delimiter", "");
|
||||
String encoding = query.get("encoding-type");
|
||||
if (encoding != null && !encoding.equals("url")) unsupported("Encoding type");
|
||||
@@ -356,8 +395,11 @@ public final class Main {
|
||||
throw new StoreException(400, "InvalidArgument", "Invalid continuation token");
|
||||
}
|
||||
}
|
||||
var page = store.list(bucket, prefix, delimiter, maxKeys, after);
|
||||
StringBuilder xml = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\"?><ListBucketResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">");
|
||||
return new ListRequest(prefix, delimiter, encoding, maxKeys, after);
|
||||
}
|
||||
|
||||
private void appendListHeader(StringBuilder xml, Map<String, String> query, ListRequest request, ObjectStorage.ListPage page) {
|
||||
String prefix = request.prefix(), delimiter = request.delimiter(), encoding = request.encoding();
|
||||
xml.append("<Name>").append(xml(bucket)).append("</Name><Prefix>").append(xml(listKey(prefix, encoding))).append("</Prefix>");
|
||||
if (!delimiter.isEmpty()) xml.append("<Delimiter>").append(xml(listKey(delimiter, encoding))).append("</Delimiter>");
|
||||
if (encoding != null) xml.append("<EncodingType>url</EncodingType>");
|
||||
@@ -365,8 +407,11 @@ public final class Main {
|
||||
.append(xml(query.get("continuation-token"))).append("</ContinuationToken>");
|
||||
if (query.containsKey("start-after")) xml.append("<StartAfter>")
|
||||
.append(xml(listKey(query.get("start-after"), encoding))).append("</StartAfter>");
|
||||
xml.append("<KeyCount>").append(page.keyCount()).append("</KeyCount><MaxKeys>").append(maxKeys)
|
||||
xml.append("<KeyCount>").append(page.keyCount()).append("</KeyCount><MaxKeys>").append(request.maxKeys())
|
||||
.append("</MaxKeys><IsTruncated>").append(page.truncated()).append("</IsTruncated>");
|
||||
}
|
||||
|
||||
private static void appendListEntries(StringBuilder xml, ObjectStorage.ListPage page, String encoding) {
|
||||
int objectAt = 0, prefixAt = 0;
|
||||
while (objectAt < page.objects().size() || prefixAt < page.prefixes().size()) {
|
||||
if (objectAt < page.objects().size() &&
|
||||
@@ -384,14 +429,6 @@ public final class Main {
|
||||
.append("</Prefix></CommonPrefixes>");
|
||||
}
|
||||
}
|
||||
if (page.truncated()) xml.append("<NextContinuationToken>")
|
||||
.append(Base64.getUrlEncoder().withoutPadding().encodeToString(page.nextKey().getBytes(StandardCharsets.UTF_8)))
|
||||
.append("</NextContinuationToken>");
|
||||
xml.append("</ListBucketResult>");
|
||||
byte[] body = xml.toString().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/xml");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
}
|
||||
|
||||
private static String listKey(String key, String encoding) {
|
||||
|
||||
@@ -69,56 +69,10 @@ final class NodeRegistry {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("SELECT pg_advisory_xact_lock(6834071092781)");
|
||||
}
|
||||
Map<String, NodeClient.Node> stored = new HashMap<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT node_id, host_id, endpoint FROM cluster_nodes WHERE state <> 'retired'")) {
|
||||
while (result.next()) {
|
||||
URI url = URI.create(result.getString(3));
|
||||
stored.put(url.toString(), new NodeClient.Node((UUID) result.getObject(1),
|
||||
(UUID) result.getObject(2), url));
|
||||
}
|
||||
}
|
||||
if (stored.isEmpty()) {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT EXISTS (SELECT 1 FROM cluster_segments)")) {
|
||||
result.next();
|
||||
if (result.getBoolean(1)) throw new IOException("Existing segments have no registered node identities");
|
||||
}
|
||||
for (URI url : urls) {
|
||||
NodeIdentity identity = NodeClient.probe(url, token);
|
||||
try (PreparedStatement insert = connection.prepareStatement(
|
||||
"INSERT INTO cluster_nodes (node_id, host_id, endpoint, state) VALUES (?, ?, ?, 'active')")) {
|
||||
insert.setObject(1, identity.nodeId());
|
||||
insert.setObject(2, identity.hostId());
|
||||
insert.setString(3, url.toString());
|
||||
insert.executeUpdate();
|
||||
}
|
||||
stored.put(url.toString(), new NodeClient.Node(identity.nodeId(), identity.hostId(), url));
|
||||
}
|
||||
}
|
||||
List<NodeClient.Node> configured = new ArrayList<>();
|
||||
Set<UUID> configuredIds = new HashSet<>();
|
||||
for (URI url : urls) {
|
||||
NodeClient.Node node = stored.get(url.toString());
|
||||
if (node == null) throw new IOException("Unregistered storage node URL: " + url);
|
||||
NodeIdentity actual = null;
|
||||
try {
|
||||
actual = NodeClient.probe(url, token);
|
||||
} catch (IOException offline) { }
|
||||
if (actual != null && (!actual.nodeId().equals(node.id()) || !actual.hostId().equals(node.hostId())))
|
||||
throw new IOException("Storage node identity changed at " + url);
|
||||
configured.add(node);
|
||||
configuredIds.add(node.id());
|
||||
}
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery(
|
||||
"SELECT DISTINCT unnest(s.replica_ids) FROM cluster_segments s JOIN cluster_objects o ON o.generation=s.generation")) {
|
||||
while (result.next()) {
|
||||
UUID id = (UUID) result.getObject(1);
|
||||
if (!configuredIds.contains(id))
|
||||
throw new IOException("A live segment refers to a node missing from CLUSTER_NODES: " + id);
|
||||
}
|
||||
}
|
||||
Map<String, NodeClient.Node> stored = registeredNodes(connection);
|
||||
if (stored.isEmpty()) registerInitialNodes(connection, urls, token, stored);
|
||||
List<NodeClient.Node> configured = configuredNodes(urls, token, stored);
|
||||
ensureLiveReplicasConfigured(connection, configured);
|
||||
NodeClient nodes = new NodeClient(configured, token, repairToken);
|
||||
connection.commit();
|
||||
return nodes;
|
||||
@@ -132,4 +86,68 @@ final class NodeRegistry {
|
||||
catch (SQLException error) { throw new IOException("Could not restore metadata connection", error); }
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, NodeClient.Node> registeredNodes(Connection connection) throws SQLException {
|
||||
Map<String, NodeClient.Node> stored = new HashMap<>();
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT node_id, host_id, endpoint FROM cluster_nodes WHERE state <> 'retired'")) {
|
||||
while (result.next()) {
|
||||
URI url = URI.create(result.getString(3));
|
||||
stored.put(url.toString(), new NodeClient.Node((UUID) result.getObject(1),
|
||||
(UUID) result.getObject(2), url));
|
||||
}
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private static void registerInitialNodes(Connection connection, List<URI> urls, String token,
|
||||
Map<String, NodeClient.Node> stored) throws SQLException, IOException {
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT EXISTS (SELECT 1 FROM cluster_segments)")) {
|
||||
result.next();
|
||||
if (result.getBoolean(1)) throw new IOException("Existing segments have no registered node identities");
|
||||
}
|
||||
for (URI url : urls) {
|
||||
NodeIdentity identity = NodeClient.probe(url, token);
|
||||
try (PreparedStatement insert = connection.prepareStatement(
|
||||
"INSERT INTO cluster_nodes (node_id, host_id, endpoint, state) VALUES (?, ?, ?, 'active')")) {
|
||||
insert.setObject(1, identity.nodeId());
|
||||
insert.setObject(2, identity.hostId());
|
||||
insert.setString(3, url.toString());
|
||||
insert.executeUpdate();
|
||||
}
|
||||
stored.put(url.toString(), new NodeClient.Node(identity.nodeId(), identity.hostId(), url));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<NodeClient.Node> configuredNodes(List<URI> urls, String token,
|
||||
Map<String, NodeClient.Node> stored) throws IOException {
|
||||
List<NodeClient.Node> configured = new ArrayList<>();
|
||||
for (URI url : urls) {
|
||||
NodeClient.Node node = stored.get(url.toString());
|
||||
if (node == null) throw new IOException("Unregistered storage node URL: " + url);
|
||||
NodeIdentity actual = null;
|
||||
try { actual = NodeClient.probe(url, token); }
|
||||
catch (IOException offline) { }
|
||||
if (actual != null && (!actual.nodeId().equals(node.id()) || !actual.hostId().equals(node.hostId())))
|
||||
throw new IOException("Storage node identity changed at " + url);
|
||||
configured.add(node);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
private static void ensureLiveReplicasConfigured(Connection connection, List<NodeClient.Node> configured)
|
||||
throws SQLException, IOException {
|
||||
Set<UUID> configuredIds = new HashSet<>();
|
||||
for (NodeClient.Node node : configured) configuredIds.add(node.id());
|
||||
try (Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery(
|
||||
"SELECT DISTINCT unnest(s.replica_ids) FROM cluster_segments s JOIN cluster_objects o ON o.generation=s.generation")) {
|
||||
while (result.next()) {
|
||||
UUID id = (UUID) result.getObject(1);
|
||||
if (!configuredIds.contains(id))
|
||||
throw new IOException("A live segment refers to a node missing from CLUSTER_NODES: " + id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,28 +28,61 @@ final class SigV4 {
|
||||
}
|
||||
|
||||
String verify(String method, URI uri, Headers headers) {
|
||||
Map<String, String> fields = authorizationFields(headers);
|
||||
String[] credential = credentialScope(fields.get("Credential"));
|
||||
String date = signingDate(headers, credential[1]);
|
||||
String payload = payloadHash(headers);
|
||||
String signedHeaders = fields.get("SignedHeaders");
|
||||
String canonicalHeaders = canonicalHeaders(headers, signedHeaders);
|
||||
String canonical = method + "\n" + encode(decode(uri.getRawPath()), true) + "\n"
|
||||
+ canonicalQuery(uri.getRawQuery()) + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payload;
|
||||
String scope = String.join("/", Arrays.copyOfRange(credential, 1, 5));
|
||||
String toSign = "AWS4-HMAC-SHA256\n" + date + "\n" + scope + "\n" + hex(hash(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
byte[] signingKey = signingKey(secretKey, credential[1], region);
|
||||
String signature = fields.get("Signature");
|
||||
if (!HEX.matcher(signature).matches() || !MessageDigest.isEqual(hmac(signingKey, toSign), HexFormat.of().parseHex(signature))) denied("Signature mismatch");
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static Map<String, String> authorizationFields(Headers headers) {
|
||||
String authorization = single(headers, "authorization");
|
||||
if (authorization == null || !authorization.startsWith("AWS4-HMAC-SHA256 ")) denied("Signed requests are required");
|
||||
Map<String,String> fields = new TreeMap<>();
|
||||
Map<String, String> fields = new TreeMap<>();
|
||||
for (String part : authorization.substring(17).split(",")) {
|
||||
String[] pair = part.trim().split("=", 2);
|
||||
if (pair.length != 2 || fields.put(pair[0], pair[1]) != null) denied("Invalid authorization header");
|
||||
}
|
||||
if (!fields.keySet().equals(java.util.Set.of("Credential", "SignedHeaders", "Signature"))) denied("Invalid authorization fields");
|
||||
String[] credential = fields.get("Credential").split("/", -1);
|
||||
return fields;
|
||||
}
|
||||
|
||||
private String[] credentialScope(String value) {
|
||||
String[] credential = value.split("/", -1);
|
||||
if (credential.length != 5 || !credential[0].equals(accessKey) || !credential[2].equals(region)
|
||||
|| !credential[3].equals("s3") || !credential[4].equals("aws4_request")) denied("Invalid credential scope");
|
||||
String date = single(headers, "x-amz-date"), payload = single(headers, "x-amz-content-sha256");
|
||||
if (date == null || !credential[1].matches("[0-9]{8}") || !date.matches("[0-9]{8}T[0-9]{6}Z") || !date.startsWith(credential[1])) denied("Invalid signing date");
|
||||
return credential;
|
||||
}
|
||||
|
||||
private String signingDate(Headers headers, String credentialDate) {
|
||||
String date = single(headers, "x-amz-date");
|
||||
if (date == null || !credentialDate.matches("[0-9]{8}") || !date.matches("[0-9]{8}T[0-9]{6}Z") || !date.startsWith(credentialDate)) denied("Invalid signing date");
|
||||
try {
|
||||
Instant signed = Instant.from(DATE.parse(date));
|
||||
if (Duration.between(signed, clock.instant()).abs().compareTo(Duration.ofMinutes(5)) > 0)
|
||||
throw new StoreException(403, "RequestTimeTooSkewed", "Request timestamp is outside the permitted window");
|
||||
} catch (java.time.DateTimeException e) { denied("Invalid signing date"); }
|
||||
return date;
|
||||
}
|
||||
|
||||
private static String payloadHash(Headers headers) {
|
||||
String payload = single(headers, "x-amz-content-sha256");
|
||||
if (payload == null || !HEX.matcher(payload).matches())
|
||||
throw new StoreException(400, "NotImplemented", "A hexadecimal SHA-256 payload hash is required; unsigned and chunk-signed payloads are unsupported");
|
||||
if (headers.containsKey("x-amz-security-token")) denied("Temporary credentials are unsupported");
|
||||
String signedHeaders = fields.get("SignedHeaders");
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static String canonicalHeaders(Headers headers, String signedHeaders) {
|
||||
String[] names = signedHeaders.split(";", -1);
|
||||
if (names.length > 32 || !signedHeaders.equals(String.join(";", Arrays.stream(names).distinct().sorted().toList()))) denied("Signed headers must be unique and sorted");
|
||||
var namesSet = java.util.Set.copyOf(Arrays.asList(names));
|
||||
@@ -66,14 +99,7 @@ final class SigV4 {
|
||||
if (value == null) denied("Missing signed header");
|
||||
canonicalHeaders.append(name).append(':').append(value.trim().replaceAll("[\\t ]+", " ")).append('\n');
|
||||
}
|
||||
String canonical = method + "\n" + encode(decode(uri.getRawPath()), true) + "\n"
|
||||
+ canonicalQuery(uri.getRawQuery()) + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payload;
|
||||
String scope = String.join("/", Arrays.copyOfRange(credential, 1, 5));
|
||||
String toSign = "AWS4-HMAC-SHA256\n" + date + "\n" + scope + "\n" + hex(hash(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
byte[] signingKey = signingKey(secretKey, credential[1], region);
|
||||
String signature = fields.get("Signature");
|
||||
if (!HEX.matcher(signature).matches() || !MessageDigest.isEqual(hmac(signingKey, toSign), HexFormat.of().parseHex(signature))) denied("Signature mismatch");
|
||||
return payload;
|
||||
return canonicalHeaders.toString();
|
||||
}
|
||||
|
||||
static String single(Headers headers, String name) {
|
||||
|
||||
Reference in new issue
Block a user