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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/main/java/dev/netcopy/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -132,6 +133,10 @@ sharedRoots, receiveRoots, resolveStateDir(stateDir),
HashCache hashCache = new HashCache();
ProgressBus progressBus = new InMemoryProgressBus();
ManifestRegistry manifestRegistry = new ManifestRegistry();
// Pre-v0.4.0 the registry's background TTL eviction was never started — entries
// accumulated forever on long-running daemons. 15 min sweep is well under the 24h
// default TTL so eviction is timely without burning CPU on a quiet box.
manifestRegistry.startBackgroundCleanup(Duration.ofMinutes(15));
ExecutorService backgroundExec = Executors.newVirtualThreadPerTaskExecutor();

ManifestPlanner.PlanConfig planConfig = new ManifestPlanner.PlanConfig(
Expand Down Expand Up @@ -160,6 +165,11 @@ sharedRoots, receiveRoots, resolveStateDir(stateDir),

Javalin app = Javalin.create(cfg -> {
cfg.showJavalinBanner = false;
// Cap body size so an authenticated client can't OOM us by posting a 10 GB
// manifest. 64 MiB is comfortably above any realistic manifest (each entry is
// ~200 bytes of JSON, so 64 MiB is ~300k files) but well below "trivial DoS".
// Javalin's default in 6.x is ~1 MiB which is too low for big-tree manifests.
cfg.http.maxRequestSize = 64L * 1024L * 1024L;
// Serve the SPA from src/main/resources/web/ (classpath).
cfg.staticFiles.add(staticCfg -> {
staticCfg.hostedPath = "/";
Expand Down
35 changes: 34 additions & 1 deletion src/main/java/dev/netcopy/server/ManifestRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ public final class ManifestRegistry implements AutoCloseable {
/** Default time-to-live for manifests stored without an explicit TTL: 24 hours. */
public static final Duration DEFAULT_TTL = Duration.ofHours(24);

/**
* Hard cap on the number of stored manifests, INCLUDING expired-but-not-yet-evicted ones.
* Even with the background cleanup running every 15 min, a burst of plan requests could
* accumulate enough entries to OOM the JVM if no upper bound were enforced. At 10k entries
* × ~5 KiB each (a typical manifest with ~25 file entries), the registry caps at ~50 MiB
* worst case — comfortable on any sensible host. When full, the OLDEST entry is evicted
* to make room (LRU-by-storedAt rather than reject — manifests are commonly produced in
* bursts and a small batch of brand-new entries is more useful than a sticky old one).
*/
public static final int MAX_ENTRIES = 10_000;

private final ConcurrentHashMap<UUID, Entry> entries = new ConcurrentHashMap<>();
private final Duration ttl;
private final TimeSource clock;
Expand Down Expand Up @@ -80,7 +91,29 @@ public Manifest store(Manifest m) {
if (closed.get()) {
throw new IllegalStateException("ManifestRegistry is closed");
}
entries.put(m.manifestId(), new Entry(m, clock.nowMillis()));
long now = clock.nowMillis();
// Evict the oldest entry if we're at the cap. Cheap when not full; O(N) with the
// current map when the cap is hit, which only happens on truly pathological burst
// patterns (10k manifests in flight). Acceptable.
if (entries.size() >= MAX_ENTRIES && !entries.containsKey(m.manifestId())) {
UUID oldestKey = null;
long oldestTs = Long.MAX_VALUE;
for (Map.Entry<UUID, Entry> kv : entries.entrySet()) {
long ts = kv.getValue().storedAt();
if (ts < oldestTs) {
oldestTs = ts;
oldestKey = kv.getKey();
}
}
if (oldestKey != null) {
entries.remove(oldestKey);
if (log.isDebugEnabled()) {
log.debug("ManifestRegistry: evicted oldest entry to stay at MAX_ENTRIES={}",
MAX_ENTRIES);
}
}
}
entries.put(m.manifestId(), new Entry(m, now));
return m;
}

Expand Down
14 changes: 14 additions & 0 deletions src/main/java/dev/netcopy/server/ProgressWebSocket.java
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ private static void onClose(WsCloseContext ctx) {
* ConcurrentHashMap forbids null keys, so we map the wildcard to this constant. */
private static final String WILDCARD_KEY = "*";

/**
* Hard per-session cap on the number of distinct transferIds (incl. the wildcard) that a
* single WebSocket session may subscribe to. Prevents an authenticated client from creating
* 2^31 left-over ProgressBus subscriptions and OOM-ing the server. The UI in practice ever
* holds either one wildcard sub or a handful of transfer-specific subs, so 256 is well
* above honest use.
*/
private static final int MAX_SUBS_PER_SESSION = 256;

private static void doSubscribe(WsContext ctx, ProgressBus bus, String transferId) {
Map<String, AutoCloseable> subs = subsOf(ctx);
if (subs == null) {
Expand All @@ -172,6 +181,11 @@ private static void doSubscribe(WsContext ctx, ProgressBus bus, String transferI
if (subs.containsKey(key)) {
return;
}
if (subs.size() >= MAX_SUBS_PER_SESSION) {
log.warn("ws: dropping Subscribe(transferId={}) on session {} — subscription cap {} reached",
transferId, ctx.sessionId(), MAX_SUBS_PER_SESSION);
return;
}
AutoCloseable subscription = bus.subscribe(transferId, ev -> deliver(ctx, ev));
AutoCloseable previous = subs.putIfAbsent(key, subscription);
if (previous != null) {
Expand Down
24 changes: 20 additions & 4 deletions src/main/java/dev/netcopy/server/RelayRoutes.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ public final class RelayRoutes {

private static final String X_TOKEN_HEADER = "X-NetCopy-Token";

/**
* Lazily-built singleton {@link HttpClient}. Pre-v0.4.0 the handler created a fresh
* HttpClient per request — JDK 21+ keeps two background selector threads alive per client
* until shutdown(), so under sustained relay traffic the JVM accumulated thread per
* request × 2 until GC reclaimed them. One shared client serves the whole process.
*/
private static final java.util.concurrent.atomic.AtomicReference<HttpClient> SHARED_CLIENT =
new java.util.concurrent.atomic.AtomicReference<>();

private static HttpClient sharedClient() {
HttpClient existing = SHARED_CLIENT.get();
if (existing != null) return existing;
HttpClient created = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(HTTP_TIMEOUT)
.build();
return SHARED_CLIENT.compareAndSet(null, created) ? created : SHARED_CLIENT.get();
}

private RelayRoutes() {}

/** Wires {@code POST /api/relay/push} onto {@code app}. */
Expand Down Expand Up @@ -112,10 +131,7 @@ private static void handle(Context ctx) {
return;
}

HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(HTTP_TIMEOUT)
.build();
HttpClient client = sharedClient();
HttpRequest req = HttpRequest.newBuilder(peerEndpoint)
.timeout(HTTP_TIMEOUT)
.header("Content-Type", "application/json")
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/dev/netcopy/server/tcp/BlobTcpServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ public dev.netcopy.metrics.ServeMetrics serveMetrics() {
private volatile boolean running;
private volatile int boundPort = -1;

/**
* Hard cap on concurrent active connections. Prevents an attacker from opening N FDs
* worth of idle (or HELLO-incomplete) sockets and starving the server of file
* descriptors. The HELLO timeout in TcpConnectionHandler caps how long an
* unauthenticated socket can hold a slot; this is the global ceiling. 1024 is a
* comfortable headroom over realistic peer-pull traffic (a single peer typically
* opens chunksPerFile=8 sockets per active file, so 1024 supports ~128 in-flight
* files concurrently — far above any sensible workload).
*/
public static final int MAX_CONCURRENT_CONNECTIONS = 1024;

/** Live connection threads — used to interrupt them on {@link #close()}. */
private final ConcurrentHashMap<Thread, Boolean> connectionThreads = new ConcurrentHashMap<>();

Expand Down Expand Up @@ -202,6 +213,15 @@ private void acceptLoop() {
try {
remote = String.valueOf(client.getRemoteAddress());
} catch (IOException ignored) { /* best-effort */ }
// Cap concurrent connections — close incoming over the limit immediately.
// Done after accept so the kernel SYN queue doesn't back up; dropping at
// the application layer is fine for a soft cap.
if (connectionThreads.size() >= MAX_CONCURRENT_CONNECTIONS) {
log.warn("blob-tcp rejecting accept from {} — at MAX_CONCURRENT_CONNECTIONS={}",
remote, MAX_CONCURRENT_CONNECTIONS);
try { client.close(); } catch (IOException ignored) { /* swallow */ }
continue;
}
log.info("blob-tcp accept from {}", remote);
String remoteFinal = remote;
Thread.ofVirtual()
Expand Down
34 changes: 33 additions & 1 deletion src/main/java/dev/netcopy/server/tcp/TcpConnectionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ final class TcpConnectionHandler {
* read the real digest from the trailing {@link Frame.DataEndV2}. */
private static final byte[] ZERO_HASH = new byte[16];

/**
* Wall-clock deadline for an unauthenticated client to deliver its HELLO frame. A
* Slowloris-style attacker that opens TCP and sends a single byte (or nothing) would
* otherwise hold a virtual thread + an FD indefinitely. SocketChannel in blocking mode
* doesn't honour SO_TIMEOUT (that's a Socket-level facility), so we implement the deadline
* as a watchdog that force-closes the channel if HELLO hasn't completed in time.
*/
private static final long HELLO_TIMEOUT_MS = 30_000L;

private final TokenGate tokens;
private final PathResolver resolver;
private final Function<UUID, Optional<Manifest>> manifests;
Expand Down Expand Up @@ -101,17 +110,40 @@ final class TcpConnectionHandler {
* {@code client} when this method returns.
*/
void handle(SocketChannel client) {
// Watchdog: force-close the channel if HELLO doesn't arrive within HELLO_TIMEOUT_MS.
// A virtual thread is the cheapest possible timer here (~kilobytes of stack, no
// shared scheduler state). The handshakeDone flag is checked when the watchdog
// wakes up — if HELLO already succeeded, the close is skipped.
final java.util.concurrent.atomic.AtomicBoolean handshakeDone =
new java.util.concurrent.atomic.AtomicBoolean(false);
Thread watchdog = Thread.ofVirtual().name("tcp-hello-watchdog").start(() -> {
try {
Thread.sleep(HELLO_TIMEOUT_MS);
} catch (InterruptedException ie) {
return;
}
if (!handshakeDone.get() && client.isOpen()) {
log.warn("handshake: timing out client {} after {}ms — no HELLO seen",
remoteOf(client), HELLO_TIMEOUT_MS);
try { client.close(); } catch (IOException ignored) { /* swallow */ }
}
});
try {
byte negotiatedVer = performHandshake(client);
handshakeDone.set(true);
watchdog.interrupt();
if (negotiatedVer < 0) {
return;
}
mainLoop(client, negotiatedVer);
} catch (ClosedChannelException e) {
// Expected on shutdown / client disconnect.
// Expected on shutdown / client disconnect / HELLO watchdog firing.
log.debug("connection closed", e);
} catch (IOException e) {
log.debug("connection IO error", e);
} finally {
handshakeDone.set(true);
watchdog.interrupt();
}
}

Expand Down
51 changes: 50 additions & 1 deletion src/main/java/dev/netcopy/state/JsonJobStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;

Expand All @@ -37,6 +44,28 @@ public final class JsonJobStore implements JobStore {
private static final String SUFFIX = ".json";
private static final String TMP_SUFFIX = ".json.tmp";

/** {@code rwx------} for the jobs directory on POSIX filesystems. Job-state files contain
* the full manifest (absolute paths, file sizes, mtimes); on multi-user hosts other local
* users should not be able to read what NetCopy is currently transferring. No-op on
* Windows (the FS doesn't support PosixFileAttributeView). */
private static final Set<PosixFilePermission> DIR_PERMS = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE);

/** {@code rw-------} for the per-job JSON files. Same rationale as DIR_PERMS. */
private static final Set<PosixFilePermission> FILE_PERMS = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);

/** Whether the JVM's default filesystem supports POSIX permissions (Linux, macOS = yes;
* Windows = no). Computed once at class init. */
private static final boolean POSIX_FS;
static {
FileSystem fs = FileSystems.getDefault();
POSIX_FS = fs.supportedFileAttributeViews().contains("posix");
}

private final Path jobsDir;
private final ObjectMapper mapper;

Expand Down Expand Up @@ -165,6 +194,14 @@ private void writeAtomic(JobState job) {
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
// Tighten perms on the tmp file before the rename — perms move with it. On
// Windows POSIX_FS is false and this is a no-op (the FS uses ACLs). Best-effort:
// a permission failure here doesn't block the write.
if (POSIX_FS) {
try {
Files.setPosixFilePermissions(tmpPath, FILE_PERMS);
} catch (IOException ignored) { /* best-effort; default umask survives */ }
}
try {
Files.move(
tmpPath,
Expand All @@ -191,7 +228,19 @@ private void writeAtomic(JobState job) {

private void ensureJobsDir() {
try {
Files.createDirectories(jobsDir);
if (POSIX_FS) {
FileAttribute<Set<PosixFilePermission>> attr =
PosixFilePermissions.asFileAttribute(DIR_PERMS);
Files.createDirectories(jobsDir, attr);
// createDirectories applies the attr only to dirs it CREATES — if the dir
// exists already (left over from a previous run with default perms) we
// tighten it now. Best-effort.
try {
Files.setPosixFilePermissions(jobsDir, DIR_PERMS);
} catch (IOException ignored) { /* best-effort */ }
} else {
Files.createDirectories(jobsDir);
}
} catch (java.nio.file.AccessDeniedException e) {
// The most common cause when running with --user "$(id -u):$(id -g)" against a
// named Docker volume: the volume's mount point was created as root by Docker
Expand Down
Loading