");
- html.append("
WSTun Server
");
- html.append("
Service Manager
");
-
- // Installed services section
- if (localServiceManager != null) {
- html.append("
Installed Services
");
- html.append("
");
-
- for (java.util.Map.Entry entry :
- localServiceManager.getInstalledServices().entrySet()) {
- String name = entry.getKey();
- seven.lab.wstun.marketplace.InstalledService svc = entry.getValue();
- String displayName = svc.getDisplayName();
- boolean enabled = svc.isEnabled();
- int instanceCount = serviceManager.getInstanceCountForService(name);
-
- html.append("- ").append(displayName).append("");
-
- if (enabled) {
- html.append("Enabled");
- if (instanceCount > 0) {
- html.append("").append(instanceCount).append(" instances");
- }
- html.append(" - Manage");
- } else {
- html.append("Disabled");
- }
-
- html.append("
");
- }
-
- if (localServiceManager.getInstalledServices().isEmpty()) {
- html.append("- No services installed
");
- }
-
- html.append("
");
- }
-
- html.append("
Registered Services
");
-
- if (serviceManager.getServiceCount() == 0) {
- html.append("
No services registered
");
- } else {
- html.append("
");
- for (ServiceManager.ServiceEntry service : serviceManager.getAllServices()) {
- html.append("- ").append(service.getName()).append("");
- html.append(" (").append(service.getType()).append(")");
- html.append(" - ").append("/").append(service.getName()).append("/main");
- html.append("
");
- }
- html.append("
");
- }
-
- html.append("
");
-
- sendHtmlResponse(ctx, request, html.toString());
- }
-
- private void sendStaticResource(ChannelHandlerContext ctx, FullHttpRequest request,
- String content, String path) {
- String contentType = "text/plain";
- if (path.endsWith(".html")) {
- contentType = "text/html; charset=UTF-8";
- } else if (path.endsWith(".js")) {
- contentType = "application/javascript";
- } else if (path.endsWith(".css")) {
- contentType = "text/css";
- } else if (path.endsWith(".json")) {
- contentType = "application/json";
- }
-
- byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.OK,
- Unpooled.wrappedBuffer(bytes)
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType);
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
-
- sendResponse(ctx, request, response);
- }
-
- private void sendHtmlResponse(ChannelHandlerContext ctx, FullHttpRequest request, String html) {
- if (html == null) {
- sendNotFound(ctx, request);
- return;
- }
- byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.OK,
- Unpooled.wrappedBuffer(bytes)
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
-
- sendResponse(ctx, request, response);
- }
-
- private void sendNotFound(ChannelHandlerContext ctx, FullHttpRequest request) {
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.NOT_FOUND,
- Unpooled.wrappedBuffer("Not Found".getBytes())
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 9);
-
- sendResponse(ctx, request, response);
- }
-
- private void sendUnauthorized(ChannelHandlerContext ctx, FullHttpRequest request, String message) {
- byte[] bytes = message.getBytes(StandardCharsets.UTF_8);
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.UNAUTHORIZED,
- Unpooled.wrappedBuffer(bytes)
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
- response.headers().set(HttpHeaderNames.WWW_AUTHENTICATE, "Bearer realm=\"wstun\"");
-
- sendResponse(ctx, request, response);
- }
-
- /**
- * Validate server-level authentication.
- * Checks Authorization header or ?token query parameter.
- */
- private boolean validateServerAuth(FullHttpRequest request) {
- if (serverConfig == null || !serverConfig.isAuthEnabled()) {
- return true;
- }
-
- String token = null;
-
- // Check Authorization header (Bearer token)
- String authHeader = request.headers().get(HttpHeaderNames.AUTHORIZATION);
- if (authHeader != null && authHeader.startsWith("Bearer ")) {
- token = authHeader.substring(7);
- }
-
- // Check query parameter as fallback
- if (token == null) {
- QueryStringDecoder decoder = new QueryStringDecoder(request.uri());
- if (decoder.parameters().containsKey("token")) {
- token = decoder.parameters().get("token").get(0);
- }
- }
-
- return serverConfig.validateServerAuth(token);
- }
-
- /**
- * Serve the admin page.
- */
- private void sendAdminPage(ChannelHandlerContext ctx, FullHttpRequest request) {
- String html = localServiceManager != null ? localServiceManager.getAdminHtml() : null;
- if (html == null) {
- sendNotFound(ctx, request);
- return;
- }
- sendHtmlResponse(ctx, request, html);
- }
-
- /**
- * Serve the libwstun.js library.
- */
- private void sendLibWstun(ChannelHandlerContext ctx, FullHttpRequest request) {
- String js = localServiceManager != null ? localServiceManager.getLibWstunJs() : null;
- if (js == null) {
- sendNotFound(ctx, request);
- return;
- }
-
- byte[] bytes = js.getBytes(StandardCharsets.UTF_8);
-
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.OK,
- Unpooled.wrappedBuffer(bytes)
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/javascript; charset=UTF-8");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
- response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=3600");
-
- sendResponse(ctx, request, response);
- }
-
- /**
- * Handle debug logs endpoint - streams logcat to the browser.
- */
- private void handleDebugLogs(ChannelHandlerContext ctx, FullHttpRequest request) {
- // Send initial response with chunked transfer encoding
- HttpResponse response = new DefaultHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.OK
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
- response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
- response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-cache");
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, corsOrigins);
-
- ctx.writeAndFlush(response);
-
- // Start a thread to stream logcat
- Thread logThread = new Thread(() -> {
- Process process = null;
- BufferedReader reader = null;
- try {
- // Start logcat process - filter to show Info and above, with timestamp
- process = Runtime.getRuntime().exec("logcat -v time *:I");
- reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
-
- String line;
- while (ctx.channel().isActive() && (line = reader.readLine()) != null) {
- // Send each log line as a chunk
- String chunk = line + "\n";
- ctx.writeAndFlush(new DefaultHttpContent(
- Unpooled.copiedBuffer(chunk, StandardCharsets.UTF_8)));
- }
- } catch (Exception e) {
- Log.e(TAG, "Error streaming logcat", e);
- } finally {
- // Clean up
- if (reader != null) {
- try { reader.close(); } catch (Exception ignored) {}
- }
- if (process != null) {
- process.destroy();
- }
-
- // Send end marker and close
- if (ctx.channel().isActive()) {
- ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
- .addListener(ChannelFutureListener.CLOSE);
- }
- }
- });
- logThread.setName("LogcatStreamer");
- logThread.setDaemon(true);
- logThread.start();
- }
-
- private void sendServiceUnavailable(ChannelHandlerContext ctx, FullHttpRequest request) {
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.SERVICE_UNAVAILABLE,
- Unpooled.wrappedBuffer("Service Unavailable".getBytes())
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 19);
-
- sendResponse(ctx, request, response);
- }
-
- private void sendInternalServerError(ChannelHandlerContext ctx, FullHttpRequest request, String message) {
- String body = "Internal Server Error: " + (message != null ? message : "Unknown error");
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.INTERNAL_SERVER_ERROR,
- Unpooled.wrappedBuffer(body.getBytes(StandardCharsets.UTF_8))
- );
-
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, body.getBytes(StandardCharsets.UTF_8).length);
-
- sendResponse(ctx, request, response);
- }
-
- private void sendResponse(ChannelHandlerContext ctx, FullHttpRequest request, FullHttpResponse response) {
- // Add CORS headers to all responses
- addCorsHeaders(response);
-
- // Ensure Content-Length is set - this is critical for keep-alive to work properly
- if (!response.headers().contains(HttpHeaderNames.CONTENT_LENGTH)) {
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
- }
-
- boolean keepAlive = HttpUtil.isKeepAlive(request);
-
- if (keepAlive) {
- // For keep-alive, set the header and don't close
- if (!response.headers().contains(HttpHeaderNames.CONNECTION)) {
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
- }
- } else {
- // For non-keep-alive, set Connection: close
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- }
-
- // Write and flush the response
- ctx.writeAndFlush(response).addListener(f -> {
- if (!keepAlive) {
- ctx.close();
- }
- });
- }
-
- /**
- * Add CORS headers to response.
- */
- private void addCorsHeaders(FullHttpResponse response) {
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, corsOrigins);
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, Authorization, X-Requested-With");
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_MAX_AGE, "86400");
- }
-
- /**
- * Add CORS headers with static origin (for relay responses).
- */
- private static void addStaticCorsHeaders(FullHttpResponse response) {
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, staticCorsOrigins);
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, Authorization, X-Requested-With");
- }
-
- /**
- * Handle CORS preflight (OPTIONS) requests.
- */
- private void sendCorsPreflightResponse(ChannelHandlerContext ctx, FullHttpRequest request) {
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.OK,
- Unpooled.EMPTY_BUFFER
- );
-
- addCorsHeaders(response);
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0);
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
-
- ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
- }
-
- @Override
- public void channelActive(ChannelHandlerContext ctx) throws Exception {
- Log.d(TAG, "Channel active: " + ctx.channel().remoteAddress());
- super.channelActive(ctx);
- }
-
- @Override
- public void channelInactive(ChannelHandlerContext ctx) throws Exception {
- Log.d(TAG, "Channel inactive: " + ctx.channel().remoteAddress());
- super.channelInactive(ctx);
- }
-
- @Override
- public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
- Log.e(TAG, "HTTP handler error", cause);
- ctx.close();
- }
-
- @Override
- public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
- if (evt instanceof io.netty.handler.timeout.IdleStateEvent) {
- Log.d(TAG, "Idle timeout, closing connection");
- ctx.close();
- } else {
- super.userEventTriggered(ctx, evt);
- }
- }
-
- // ==================== Streaming Response Methods ====================
-
- /**
- * Start a streaming HTTP response (send headers, prepare for chunks).
- */
- public static void startStreamingResponse(ChannelHandlerContext ctx, String requestId,
- int status, JsonObject headers) {
- HttpResponse response = new DefaultHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.valueOf(status)
- );
-
- // Add CORS headers
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, staticCorsOrigins);
- response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
-
- // Use chunked transfer encoding for streaming
- response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
-
- // Copy headers from payload
- if (headers != null) {
- for (String key : headers.keySet()) {
- // Skip Content-Length as we're using chunked encoding
- if (!key.equalsIgnoreCase("Content-Length")) {
- response.headers().set(key, headers.get(key).getAsString());
- }
- }
- }
-
- // Flush the headers immediately so the client knows the response has started
- ctx.writeAndFlush(response);
- streamingResponses.put(requestId, ctx);
- Log.d(TAG, "Started streaming response for: " + requestId);
- }
-
- /**
- * Send a chunk of streaming data.
- */
- public static void sendStreamingChunk(ChannelHandlerContext ctx, String chunkBase64) {
- if (ctx == null || !ctx.channel().isActive()) {
- return;
- }
-
- byte[] data = Base64.decode(chunkBase64, Base64.NO_WRAP);
- ByteBuf buf = Unpooled.wrappedBuffer(data);
- ctx.writeAndFlush(new io.netty.handler.codec.http.DefaultHttpContent(buf));
- }
-
- /**
- * End a streaming response.
- */
- public static void endStreamingResponse(ChannelHandlerContext ctx) {
- if (ctx == null || !ctx.channel().isActive()) {
- return;
- }
-
- ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
- .addListener(ChannelFutureListener.CLOSE);
- Log.d(TAG, "Ended streaming response");
- }
-
- /**
- * Send error and close streaming response.
- */
- public static void sendStreamingError(ChannelHandlerContext ctx, String error) {
- if (ctx == null || !ctx.channel().isActive()) {
- return;
- }
-
- // If headers not sent yet, send error response
- byte[] errorBytes = error.getBytes(StandardCharsets.UTF_8);
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.INTERNAL_SERVER_ERROR,
- Unpooled.wrappedBuffer(errorBytes)
- );
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, errorBytes.length);
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- addStaticCorsHeaders(response);
-
- ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
- }
-
- /**
- * Request file stream from owner for relay download.
- */
- public static void requestFileStream(ServiceManager serviceManager, String requestId,
- String fileId, ChannelHandlerContext httpCtx) {
- ServiceManager.FileInfo file = serviceManager.getFile(fileId);
- if (file == null) {
- sendNotFoundResponse(httpCtx);
- return;
- }
-
- Channel ownerChannel = file.getOwnerChannel();
- if (ownerChannel == null || !ownerChannel.isActive()) {
- sendServiceUnavailableResponse(httpCtx);
- return;
- }
-
- // Send file request to owner
- Message message = new Message(Message.TYPE_FILE_REQUEST);
- JsonObject payload = new JsonObject();
- payload.addProperty("requestId", requestId);
- payload.addProperty("fileId", fileId);
- message.setPayload(payload);
-
- ownerChannel.writeAndFlush(new TextWebSocketFrame(message.toJson()));
- Log.d(TAG, "Requested file stream from owner: " + fileId);
- }
-
- private static void sendNotFoundResponse(ChannelHandlerContext ctx) {
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.NOT_FOUND,
- Unpooled.wrappedBuffer("File not found".getBytes())
- );
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 14);
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- addStaticCorsHeaders(response);
- ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
- }
-
- private static void sendServiceUnavailableResponse(ChannelHandlerContext ctx) {
- FullHttpResponse response = new DefaultFullHttpResponse(
- HttpVersion.HTTP_1_1,
- HttpResponseStatus.SERVICE_UNAVAILABLE,
- Unpooled.wrappedBuffer("File owner not connected".getBytes())
- );
- response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
- response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 24);
- response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
- addStaticCorsHeaders(response);
- ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
- }
-
- /**
- * Handle file download request - streams file from owner client.
- * URL format: /fileshare/download/{globalFileId}
- * where globalFileId = userId/localFileId
- * This allows stateless routing - the server doesn't need to store file info.
- */
- private void handleFileDownload(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) {
- // Extract global file ID from path: /fileshare/download/{globalFileId}
- String globalFileId;
- try {
- globalFileId = java.net.URLDecoder.decode(pathParts[3], "UTF-8");
- } catch (Exception e) {
- globalFileId = pathParts[3];
- }
-
- Log.d(TAG, "File download request: " + globalFileId);
-
- // Parse globalFileId to extract userId
- // Format: userId/localFileId
- String ownerId = null;
- String localFileId = globalFileId;
-
- if (globalFileId.contains("/")) {
- int slashIdx = globalFileId.indexOf('/');
- ownerId = globalFileId.substring(0, slashIdx);
- localFileId = globalFileId.substring(slashIdx + 1);
- }
-
- if (ownerId == null || ownerId.isEmpty()) {
- // Fallback: try the old registry-based lookup for backward compatibility
- ServiceManager.FileInfo file = serviceManager.getFile(globalFileId);
- if (file != null) {
- ownerId = file.getOwnerId();
- Channel ownerChannel = file.getOwnerChannel();
- if (ownerChannel != null && ownerChannel.isActive()) {
- sendFileRequest(ctx, request, ownerChannel, globalFileId);
- return;
- }
- }
- sendNotFoundResponse(ctx);
- return;
- }
-
- // Find the owner's channel by userId
- ServiceManager.ClientInfo ownerClient = serviceManager.getClient(ownerId);
- if (ownerClient == null || ownerClient.getChannel() == null || !ownerClient.getChannel().isActive()) {
- Log.w(TAG, "File owner not connected: " + ownerId);
- sendServiceUnavailableResponse(ctx);
- return;
- }
-
- sendFileRequest(ctx, request, ownerClient.getChannel(), globalFileId);
- }
-
- /**
- * Send a file request to the owner and set up pending request for streaming response.
- */
- private void sendFileRequest(ChannelHandlerContext ctx, FullHttpRequest request,
- Channel ownerChannel, String fileId) {
- // Create pending request for streaming response
- String requestId = String.valueOf(System.currentTimeMillis()) + "-" + (int)(Math.random() * 10000);
- PendingRequest pending = new PendingRequest(requestId, ctx, request, "fileshare");
- requestManager.addPendingRequest(pending);
-
- // Send file_request to owner
- Message message = new Message(Message.TYPE_FILE_REQUEST);
- JsonObject payload = new JsonObject();
- payload.addProperty("requestId", requestId);
- payload.addProperty("fileId", fileId);
- message.setPayload(payload);
-
- ownerChannel.writeAndFlush(new TextWebSocketFrame(message.toJson()));
- Log.d(TAG, "Sent file request to owner: " + fileId);
- }
-}
diff --git a/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java b/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java
deleted file mode 100644
index 248a212..0000000
--- a/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java
+++ /dev/null
@@ -1,433 +0,0 @@
-package seven.lab.wstun.server;
-
-import android.content.Context;
-import android.util.Log;
-
-import com.google.gson.Gson;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonObject;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
-
-import seven.lab.wstun.config.ServerConfig;
-import seven.lab.wstun.marketplace.InstalledService;
-import seven.lab.wstun.marketplace.MarketplaceService;
-import seven.lab.wstun.marketplace.ServiceManifest;
-
-/**
- * Manages local services including built-in and marketplace-installed services.
- * Services can be enabled/disabled and have endpoints attached/detached dynamically.
- */
-public class LocalServiceManager {
-
- private static final String TAG = "LocalServiceManager";
- private static final Gson gson = new Gson();
-
- private final Context context;
- private final ServerConfig config;
- private final MarketplaceService marketplaceService;
-
- // Service status tracking (for running instances)
- private final Map