diff --git a/.idea/misc.xml b/.idea/misc.xml index f1e8302..4e5bb42 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,5 @@ - - + + + + diff --git a/pom.xml b/pom.xml index 9661e0f..e9126ef 100644 --- a/pom.xml +++ b/pom.xml @@ -104,7 +104,7 @@ org.openautonomousconnection WebServer - 1.0.0-BETA.1.3 + 1.0.0-BETA.1.5 org.projectlombok diff --git a/src/main/java/org/openautonomousconnection/oac2web/api.java b/src/main/java/org/openautonomousconnection/oac2web/api.java index ef004d3..7a39187 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/api.java +++ b/src/main/java/org/openautonomousconnection/oac2web/api.java @@ -3,6 +3,7 @@ package org.openautonomousconnection.oac2web; import com.google.gson.JsonObject; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; @@ -18,16 +19,19 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +@Route(path = "api") public class api implements WebPage { @Override public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { if (webPageContext.request.getMethod() == WebRequestMethod.POST) { - String hostIp = webPageContext.request.getHeaders().get("hostip"); + String hostIp = webPageContext.request.getHeaders().get("ip"); String description = webPageContext.request.getHeaders().get("description"); - int port = 0; + int tcp = 0; + int udp = 0; try { - port = Integer.parseInt(webPageContext.request.getHeaders().get("port")); + tcp = Integer.parseInt(webPageContext.request.getHeaders().get("tcp")); + udp = Integer.parseInt(webPageContext.request.getHeaders().get("udp")); } catch (NumberFormatException exception) { return new WebResponsePacket(400, "text/plain", webPageContext.request.getHeaders(), ("Bad request:\n" + exception.getMessage()).getBytes()); @@ -38,9 +42,10 @@ public class api implements WebPage { try { HashMap dataMap = new HashMap<>(); - dataMap.put("insIp", hostIp); - dataMap.put("insPort", String.valueOf(port)); - dataMap.put("insDesc", description); + dataMap.put("ip", hostIp); + dataMap.put("tcp", String.valueOf(tcp)); + dataMap.put("udp", String.valueOf(udp)); + dataMap.put("desc", description); URL url = new URL("https://open-autonomous-connection.org/api/add.php"); HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); diff --git a/src/main/java/org/openautonomousconnection/oac2web/download.java b/src/main/java/org/openautonomousconnection/oac2web/download.java index 799def3..3f78e38 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/download.java +++ b/src/main/java/org/openautonomousconnection/oac2web/download.java @@ -1,32 +1,21 @@ package org.openautonomousconnection.oac2web; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.HttpsProxy; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Scanner; - +/** + * Proxies the HTTPS download page through OAC. + */ +@Route(path = "download.html") public class download implements WebPage { + @Override - public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { - String content = null; - URLConnection connection = null; - - try { - connection = new URL("https://open-autonomous-connection.org/download.html").openConnection(); - Scanner scanner = new Scanner(connection.getInputStream()); - scanner.useDelimiter("\\Z"); - content = scanner.next(); - scanner.close(); - }catch ( Exception ex ) { - content = ex.getMessage(); - ex.printStackTrace(); - } - - return new WebResponsePacket(308, "text/html", new HashMap<>(), content.getBytes(StandardCharsets.UTF_8)); + public WebResponsePacket handle(WebPageContext webPageContext) { + if (webPageContext.request.getPath().equalsIgnoreCase("download.html")) + return HttpsProxy.proxyGet(webPageContext, "https://open-autonomous-connection.org/download.html"); + return null; } } diff --git a/src/main/java/org/openautonomousconnection/oac2web/frontend/dashboard.java b/src/main/java/org/openautonomousconnection/oac2web/frontend/dashboard.java new file mode 100644 index 0000000..035f1f7 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/frontend/dashboard.java @@ -0,0 +1,175 @@ +package org.openautonomousconnection.oac2web.frontend; + +import org.openautonomousconnection.oac2web.utils.Oac2WebApp; +import org.openautonomousconnection.oac2web.utils.RegistrarDao; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.protocol.side.web.ProtocolWebServer; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; +import org.openautonomousconnection.webserver.api.Route; +import org.openautonomousconnection.webserver.api.SessionContext; +import org.openautonomousconnection.webserver.api.WebPage; +import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.Html; +import org.openautonomousconnection.webserver.utils.RequestParams; + +import java.util.HashMap; +import java.util.Map; + +/** + * Dashboard page: lists owned infonames and allows modifications. + * + * Requires valid session. + * + * POST headers (action-based): + * - action=create_infoname, tln, infoname + * - action=delete_infoname, id + * - action=add_record, id (infoname_id), sub, type, value, ttl, priority, port, weight + */ +@Route(path = "ins/dashboard") +public final class dashboard implements WebPage { + + @Override + public WebResponsePacket handle(WebPageContext ctx) throws Exception { + SessionContext session = SessionContext.from(ctx.client, (ProtocolWebServer) ctx.client.getServer(), ctx.request.getHeaders()); + if (!session.isValid() || session.getUser() == null) { + return new WebResponsePacket(401, "text/plain", new HashMap<>(), Html.utf8("Authentication required (session).")); + } + + int userId; + try { + userId = Integer.parseInt(session.getUser()); + } catch (Exception e) { + return new WebResponsePacket(401, "text/plain", new HashMap<>(), Html.utf8("Invalid session user.")); + } + + String msg = null; + String err = null; + + if (ctx.request.getMethod() == WebRequestMethod.POST) { + RequestParams p = new RequestParams(ctx.request); + String action = p.getOr("action", "").trim(); + + try { + Oac2WebApp app = Oac2WebApp.get(); + RegistrarDao dao = app.dao(); + + if ("create_infoname".equalsIgnoreCase(action)) { + String tln = p.get("tln"); + String info = p.get("infoname"); + + if (tln == null || tln.isBlank() || info == null || info.isBlank()) { + err = "Missing tln / infoname."; + } else { + Integer tlnId = dao.findTlnId(tln.trim()).orElse(null); + if (tlnId == null) { + err = "Unknown TLN: " + tln; + } else { + int newId = dao.createInfoName(tlnId, info.trim(), userId); + msg = "Created infoname id=" + newId + " (" + info + "." + tln + ")"; + } + } + } else if ("delete_infoname".equalsIgnoreCase(action)) { + int id = p.getInt("id", -1); + if (id <= 0) { + err = "Invalid id."; + } else if (!dao.isOwnerOfInfoName(id, userId)) { + err = "Not owner (edit/delete requires ownership)."; + } else { + dao.deleteInfoName(id); + msg = "Deleted infoname id=" + id; + } + } else if ("add_record".equalsIgnoreCase(action)) { + int infonameId = p.getInt("id", -1); + if (infonameId <= 0) { + err = "Invalid infoname id."; + } else if (!dao.isOwnerOfInfoName(infonameId, userId)) { + err = "Not owner (edit/delete requires ownership)."; + } else { + String sub = p.get("sub"); + String type = p.getOr("type", "").trim().toUpperCase(); + String value = p.get("value"); + + int ttl = p.getInt("ttl", 3600); + Integer priority = (p.get("priority") == null) ? null : p.getInt("priority", 0); + Integer port = (p.get("port") == null) ? null : p.getInt("port", 0); + Integer weight = (p.get("weight") == null) ? null : p.getInt("weight", 0); + + if (type.isBlank() || value == null || value.isBlank()) { + err = "Missing type/value."; + } else { + Integer subId = dao.ensureSubname(infonameId, sub); + int rid = dao.addRecord(infonameId, subId, type, value.trim(), ttl, priority, port, weight); + msg = "Added record id=" + rid + " type=" + type; + } + } + } else if (!action.isBlank()) { + err = "Unknown action: " + action; + } + } catch (Exception e) { + err = "Action failed: " + e.getMessage(); + } + } + + return render(ctx, userId, msg, err); + } + + private WebResponsePacket render(WebPageContext ctx, int userId, String msg, String err) throws Exception { + Oac2WebApp app = Oac2WebApp.get(); + RegistrarDao.InfoNameRow[] owned = app.dao().listOwnedInfoNames(userId); + + StringBuilder list = new StringBuilder(); + if (owned.length == 0) { + list.append("

No infonames yet.

"); + } else { + list.append("
    "); + for (RegistrarDao.InfoNameRow r : owned) { + list.append("
  • ") + .append("").append(Html.esc(r.info())).append(".").append(Html.esc(r.tln())).append("") + .append(" (id=").append(r.id()).append(")") + .append("
  • "); + } + list.append("
"); + } + + String body = """ +
+

Dashboard

+

Owned by users.id = %d

+ %s + %s + +

Create InfoName

+

POST headers: action=create_infoname, tln, infoname

+ +

Add Record

+

POST headers: action=add_record, id (infoname id), optional sub, type, value, optional ttl, priority, port, weight

+ +

Delete InfoName

+

POST headers: action=delete_infoname, id

+ +

Your InfoNames

+ %s + +
+ +
+
+ """.formatted( + userId, + msg == null ? "" : "

" + Html.esc(msg) + "

", + err == null ? "" : "

" + Html.esc(err) + "

", + list + ); + + String html = Html.page("Dashboard", body); + + Map headers = new HashMap<>(); + // keep the same session header visible to client, if needed + if (ctx.request.getHeaders() != null) { + String sess = ctx.request.getHeaders().get("session"); + if (sess != null) headers.put("session", sess); + } + + return new WebResponsePacket(200, "text/html", headers, Html.utf8(html)); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/frontend/index.java b/src/main/java/org/openautonomousconnection/oac2web/frontend/index.java new file mode 100644 index 0000000..acadd4f --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/frontend/index.java @@ -0,0 +1,34 @@ +package org.openautonomousconnection.oac2web.frontend; + +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; +import org.openautonomousconnection.webserver.api.WebPage; +import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.Html; + +import java.util.HashMap; + +/** + * Landing page for the registrar frontend. + */ +@Route(path = "ins/index.html") +public final class index implements WebPage { + + @Override + public WebResponsePacket handle(WebPageContext ctx) { + String html = Html.page("OAC INS Registrar", """ +
+

OAC INS Registrar

+

Server-side pages (oac2web). No extra endpoints.

+ +

POST parameters are expected via headers (e.g. username, password, action).

+
+ """); + + return new WebResponsePacket(200, "text/html", new HashMap<>(), Html.utf8(html)); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/frontend/login.java b/src/main/java/org/openautonomousconnection/oac2web/frontend/login.java new file mode 100644 index 0000000..7fc0434 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/frontend/login.java @@ -0,0 +1,82 @@ +package org.openautonomousconnection.oac2web.frontend; + +import org.openautonomousconnection.oac2web.utils.*; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.protocol.side.web.ProtocolWebServer; +import org.openautonomousconnection.protocol.side.web.managers.SessionManager; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; +import org.openautonomousconnection.webserver.api.Route; +import org.openautonomousconnection.webserver.api.WebPage; +import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.Html; +import org.openautonomousconnection.webserver.utils.RequestParams; + +import java.util.HashMap; +import java.util.Map; + +/** + * Login page. + * + * POST headers expected: + * - username + * - password + * + * Creates a session with SessionManager. + */ +@Route(path = "ins/login") +public final class login implements WebPage { + + @Override + public WebResponsePacket handle(WebPageContext ctx) throws Exception { + if (ctx.request.getMethod() != WebRequestMethod.POST) { + return renderForm(null); + } + + RequestParams p = new RequestParams(ctx.request); + String username = p.get("username"); + String password = p.get("password"); + + if (username == null || username.isBlank() || password == null || password.isBlank()) { + return renderForm("Missing username/password (send via headers)."); + } + + Oac2WebApp app = Oac2WebApp.get(); + String usernameHash = Sha256.hex(username.trim()); + + RegistrarDao.UserRow u = app.dao().findUserByUsernameHash(usernameHash).orElse(null); + if (u == null) return renderForm("Invalid credentials."); + + boolean ok = app.passwordHasher().verify(password, u.passwordEncoded()); + if (!ok) return renderForm("Invalid credentials."); + + String ip = (ctx.client.getConnection().getSocket() != null && ctx.client.getConnection().getSocket().getInetAddress() != null) + ? ctx.client.getConnection().getSocket().getInetAddress().getHostAddress() + : ""; + + String ua = ctx.request.getHeaders() != null ? ctx.request.getHeaders().getOrDefault("user-agent", "") : ""; + + String session = SessionManager.create(String.valueOf(u.id()), ip, ua, (ProtocolWebServer) ctx.client.getServer()); + + Map headers = new HashMap<>(); + headers.put("session", session); + headers.put("location", "/dashboard"); + return new WebResponsePacket(302, "text/plain", headers, new byte[0]); + } + + private WebResponsePacket renderForm(String err) { + String body = """ +
+

Login

+ %s +

Send a POST request with headers username and password.

+
+ + +
+
+ """.formatted(err == null ? "" : "

" + Html.esc(err) + "

"); + + String html = Html.page("Login", body); + return new WebResponsePacket(200, "text/html", new HashMap<>(), Html.utf8(html)); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/frontend/register.java b/src/main/java/org/openautonomousconnection/oac2web/frontend/register.java new file mode 100644 index 0000000..7df4aff --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/frontend/register.java @@ -0,0 +1,96 @@ +package org.openautonomousconnection.oac2web.frontend; + +import org.openautonomousconnection.oac2web.utils.Oac2WebApp; +import org.openautonomousconnection.oac2web.utils.Sha256; +import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.protocol.side.web.ProtocolWebServer; +import org.openautonomousconnection.protocol.side.web.managers.SessionManager; +import org.openautonomousconnection.protocol.versions.v1_0_0.beta.WebRequestMethod; +import org.openautonomousconnection.webserver.api.Route; +import org.openautonomousconnection.webserver.api.WebPage; +import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.Html; +import org.openautonomousconnection.webserver.utils.RequestParams; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registration page. + * + * POST headers expected: + * - username + * - password + * + * Stores: + * - users.username = sha256(username) + * - users.password = PBKDF2$sha256$... + */ +@Route(path = "ins/register") +public final class register implements WebPage { + + @Override + public WebResponsePacket handle(WebPageContext ctx) throws Exception { + if (ctx.request.getMethod() != WebRequestMethod.POST) { + return renderForm(null, null); + } + + RequestParams p = new RequestParams(ctx.request); + + String username = p.get("username"); + String password = p.get("password"); + + if (username == null || username.isBlank() || password == null || password.isBlank()) { + return renderForm("Missing username/password (send via headers).", null); + } + + Oac2WebApp app = Oac2WebApp.get(); + + String usernameHash = Sha256.hex(username.trim()); + String passwordEnc = app.passwordHasher().hash(password); + + try { + int userId = app.dao().createUser(usernameHash, passwordEnc); + + String ip = (ctx.client.getConnection().getSocket() != null && ctx.client.getConnection().getSocket().getInetAddress() != null) + ? ctx.client.getConnection().getSocket().getInetAddress().getHostAddress() + : ""; + + String ua = ctx.request.getHeaders() != null ? ctx.request.getHeaders().getOrDefault("user-agent", "") : ""; + + // SessionManager user string: we store numeric users.id as string. + String session = SessionManager.create(String.valueOf(userId), ip, ua, (ProtocolWebServer) ctx.client.getServer()); + + Map headers = new HashMap<>(); + headers.put("session", session); + headers.put("location", "/dashboard"); + + return new WebResponsePacket(302, "text/plain", headers, new byte[0]); + + } catch (Exception e) { + // likely UNIQUE violation on users.username (hashed) + return renderForm("Register failed: " + e.getMessage(), null); + } + } + + private WebResponsePacket renderForm(String err, String ok) { + String body = """ +
+

Register

+ %s + %s +

Send a POST request with headers username and password.

+
+ + +
+
+ """.formatted( + err == null ? "" : "

" + Html.esc(err) + "

", + ok == null ? "" : "

" + Html.esc(ok) + "

" + ); + + String html = Html.page("Register", body); + return new WebResponsePacket(200, "text/html", new HashMap<>(), Html.utf8(html)); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/index.java b/src/main/java/org/openautonomousconnection/oac2web/index.java index a234d17..a03a652 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/index.java +++ b/src/main/java/org/openautonomousconnection/oac2web/index.java @@ -1,33 +1,21 @@ package org.openautonomousconnection.oac2web; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.HttpsProxy; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.Scanner; - +/** + * Proxies the HTTPS wiki page through OAC. + */ +@Route(path = "index.html") public class index implements WebPage { + @Override - public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { - String content = null; - URLConnection connection = null; - - try { - connection = new URL("https://open-autonomous-connection.org/index.html").openConnection(); - Scanner scanner = new Scanner(connection.getInputStream()); - scanner.useDelimiter("\\Z"); - content = scanner.next(); - scanner.close(); - }catch ( Exception ex ) { - content = ex.getMessage(); - ex.printStackTrace(); - } - - return new WebResponsePacket(308, "text/html", new HashMap<>(), content.getBytes(StandardCharsets.UTF_8)); + public WebResponsePacket handle(WebPageContext webPageContext) { + if (webPageContext.request.getPath().equalsIgnoreCase("index.html")) + return HttpsProxy.proxyGet(webPageContext, "https://open-autonomous-connection.org/index.html"); + return null; } } diff --git a/src/main/java/org/openautonomousconnection/oac2web/ins.java b/src/main/java/org/openautonomousconnection/oac2web/ins.java index a54fc1e..476490d 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/ins.java +++ b/src/main/java/org/openautonomousconnection/oac2web/ins.java @@ -1,32 +1,21 @@ package org.openautonomousconnection.oac2web; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.HttpsProxy; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Scanner; - +/** + * Proxies the HTTPS INS page through OAC. + */ +@Route(path = "ins.php") public class ins implements WebPage { - @Override - public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { - String content = null; - URLConnection connection = null; - - try { - connection = new URL("https://open-autonomous-connection.org/ins.php").openConnection(); - Scanner scanner = new Scanner(connection.getInputStream()); - scanner.useDelimiter("\\Z"); - content = scanner.next(); - scanner.close(); - }catch ( Exception ex ) { - content = ex.getMessage(); - ex.printStackTrace(); - } - return new WebResponsePacket(308, "text/html", new HashMap<>(), content.getBytes(StandardCharsets.UTF_8)); + @Override + public WebResponsePacket handle(WebPageContext webPageContext) { + if (webPageContext.request.getPath().equalsIgnoreCase("ins.php")) + return HttpsProxy.proxyGet(webPageContext, "https://open-autonomous-connection.org/ins.php"); + return null; } } diff --git a/src/main/java/org/openautonomousconnection/oac2web/license.java b/src/main/java/org/openautonomousconnection/oac2web/license.java index 9d42949..542a91d 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/license.java +++ b/src/main/java/org/openautonomousconnection/oac2web/license.java @@ -1,32 +1,21 @@ package org.openautonomousconnection.oac2web; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.HttpsProxy; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Scanner; - +/** + * Proxies the HTTPS license page through OAC. + */ +@Route(path = "license.html") public class license implements WebPage { + @Override - public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { - String content = null; - URLConnection connection = null; - - try { - connection = new URL("https://open-autonomous-connection.org/license.html").openConnection(); - Scanner scanner = new Scanner(connection.getInputStream()); - scanner.useDelimiter("\\Z"); - content = scanner.next(); - scanner.close(); - }catch ( Exception ex ) { - content = ex.getMessage(); - ex.printStackTrace(); - } - - return new WebResponsePacket(308, "text/html", new HashMap<>(), content.getBytes(StandardCharsets.UTF_8)); + public WebResponsePacket handle(WebPageContext webPageContext) { + if (webPageContext.request.getPath().equalsIgnoreCase("license.html")) + return HttpsProxy.proxyGet(webPageContext, "https://open-autonomous-connection.org/license.html"); + return null; } } diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Hex.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Hex.java new file mode 100644 index 0000000..30290d8 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Hex.java @@ -0,0 +1,48 @@ +package org.openautonomousconnection.oac2web.utils; + +/** + * Hex encoding helper. + */ +public final class Hex { + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Hex() {} + + /** + * Encodes bytes to lowercase hex. + * + * @param data bytes + * @return hex + */ + public static String lower(byte[] data) { + if (data == null) return ""; + char[] out = new char[data.length * 2]; + int i = 0; + for (byte b : data) { + out[i++] = HEX[(b >>> 4) & 0x0F]; + out[i++] = HEX[b & 0x0F]; + } + return new String(out); + } + + /** + * Decodes lowercase/uppercase hex to bytes. + * + * @param hex hex string + * @return bytes + */ + public static byte[] decode(String hex) { + if (hex == null) return new byte[0]; + String s = hex.trim(); + if (s.length() % 2 != 0) throw new IllegalArgumentException("Invalid hex length"); + byte[] out = new byte[s.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("Invalid hex char"); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebApp.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebApp.java new file mode 100644 index 0000000..f06d13a --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebApp.java @@ -0,0 +1,58 @@ +package org.openautonomousconnection.oac2web.utils; + +import java.io.File; +import java.io.IOException; + +/** + * Global app context for oac2web pages (config + dao + crypto). + */ +public final class Oac2WebApp { + + private static volatile Oac2WebApp INSTANCE; + + private final Oac2WebConfig config; + private final Oac2WebDb db; + private final RegistrarDao dao; + private final Pbkdf2PasswordHasher passwordHasher; + + private Oac2WebApp(Oac2WebConfig config) { + this.config = config; + this.db = new Oac2WebDb(config.jdbcUrl(), config.jdbcUser(), config.jdbcPassword()); + this.dao = new RegistrarDao(db); + this.passwordHasher = new Pbkdf2PasswordHasher(config.pbkdf2Iterations(), config.pbkdf2SaltBytes(), config.pbkdf2KeyBytes()); + } + + /** + * Initializes or returns global instance. + * + * @return singleton + */ + public static Oac2WebApp get() { + Oac2WebApp v = INSTANCE; + if (v != null) return v; + synchronized (Oac2WebApp.class) { + if (INSTANCE == null) { + try { + INSTANCE = new Oac2WebApp(new Oac2WebConfig(new File("ins.properties"))); + } catch (IOException e) { + throw new IllegalStateException("Failed to load ins.properties", e); + } + } + return INSTANCE; + } + } + + /** + * @return dao + */ + public RegistrarDao dao() { + return dao; + } + + /** + * @return password hasher + */ + public Pbkdf2PasswordHasher passwordHasher() { + return passwordHasher; + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebConfig.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebConfig.java new file mode 100644 index 0000000..0a4c6e4 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebConfig.java @@ -0,0 +1,91 @@ +package org.openautonomousconnection.oac2web.utils; + +import dev.unlegitdqrk.unlegitlibrary.file.ConfigurationManager; + +import java.io.File; +import java.io.IOException; + +/** + * Reads configuration for the oac2web registrar frontend. + */ +public final class Oac2WebConfig { + + private final ConfigurationManager cfg; + + /** + * Creates a config wrapper backed by the given properties file. + * + * @param file config file + * @throws IOException if loading fails + */ + public Oac2WebConfig(File file) throws IOException { + this.cfg = new ConfigurationManager(file); + this.cfg.loadProperties(); + + ensureDefaults(); + this.cfg.saveProperties(); + } + + private void ensureDefaults() { + if (!cfg.isSet("db.url")) { + cfg.set("db.url", "jdbc:mysql://localhost:3306/oac_ins?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC"); + } + if (!cfg.isSet("db.user")) { + cfg.set("db.user", "username"); + } + if (!cfg.isSet("db.password")) { + cfg.set("db.password", "password"); + } + if (!cfg.isSet("security.pbkdf2.iterations")) { + cfg.set("security.pbkdf2.iterations", 120000); + } + if (!cfg.isSet("security.pbkdf2.saltBytes")) { + cfg.set("security.pbkdf2.saltBytes", 16); + } + if (!cfg.isSet("security.pbkdf2.keyBytes")) { + cfg.set("security.pbkdf2.keyBytes", 32); + } + } + + /** + * @return JDBC url + */ + public String jdbcUrl() { + return cfg.getString("db.url"); + } + + /** + * @return JDBC user + */ + public String jdbcUser() { + return cfg.getString("db.user"); + } + + /** + * @return JDBC password + */ + public String jdbcPassword() { + return cfg.getString("db.password"); + } + + /** + * @return PBKDF2 iterations + */ + public int pbkdf2Iterations() { + return cfg.getInt("security.pbkdf2.iterations"); + } + + /** + * @return PBKDF2 salt length in bytes + */ + public int pbkdf2SaltBytes() { + return cfg.getInt("security.pbkdf2.saltBytes"); + } + + /** + * @return PBKDF2 derived key length in bytes + */ + public int pbkdf2KeyBytes() { + return cfg.getInt("security.pbkdf2.keyBytes"); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebDb.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebDb.java new file mode 100644 index 0000000..a315b13 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Oac2WebDb.java @@ -0,0 +1,39 @@ +package org.openautonomousconnection.oac2web.utils; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Objects; + +/** + * Very small JDBC helper for the oac2web registrar frontend. + */ +public final class Oac2WebDb { + + private final String url; + private final String user; + private final String password; + + /** + * Creates a JDBC helper. + * + * @param url JDBC url + * @param user JDBC username + * @param password JDBC password + */ + public Oac2WebDb(String url, String user, String password) { + this.url = Objects.requireNonNull(url, "url"); + this.user = Objects.requireNonNull(user, "user"); + this.password = Objects.requireNonNull(password, "password"); + } + + /** + * Opens a new JDBC connection. + * + * @return connection + * @throws SQLException if open fails + */ + public Connection open() throws SQLException { + return DriverManager.getConnection(url, user, password); + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Pbkdf2PasswordHasher.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Pbkdf2PasswordHasher.java new file mode 100644 index 0000000..77ba4d7 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Pbkdf2PasswordHasher.java @@ -0,0 +1,104 @@ +package org.openautonomousconnection.oac2web.utils; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.SecureRandom; +import java.util.Objects; + +/** + * PBKDF2 password hashing (PBKDF2WithHmacSHA256). + * + * Storage format: + * PBKDF2$sha256$$$ + */ +public final class Pbkdf2PasswordHasher { + + private final SecureRandom rng = new SecureRandom(); + private final int iterations; + private final int saltBytes; + private final int keyBytes; + + /** + * Creates a hasher. + * + * @param iterations iterations + * @param saltBytes salt bytes + * @param keyBytes derived key bytes + */ + public Pbkdf2PasswordHasher(int iterations, int saltBytes, int keyBytes) { + if (iterations < 10_000) throw new IllegalArgumentException("iterations too low"); + if (saltBytes < 8) throw new IllegalArgumentException("saltBytes too low"); + if (keyBytes < 16) throw new IllegalArgumentException("keyBytes too low"); + this.iterations = iterations; + this.saltBytes = saltBytes; + this.keyBytes = keyBytes; + } + + /** + * Hashes a password into the storage format. + * + * @param password raw password + * @return encoded string + */ + public String hash(String password) { + Objects.requireNonNull(password, "password"); + byte[] salt = new byte[saltBytes]; + rng.nextBytes(salt); + + byte[] dk = derive(password.toCharArray(), salt, iterations, keyBytes); + return "PBKDF2$sha256$" + iterations + "$" + Hex.lower(salt) + "$" + Hex.lower(dk); + } + + /** + * Verifies a password against stored format. + * + * @param password raw password + * @param stored stored string + * @return true if valid + */ + public boolean verify(String password, String stored) { + if (password == null || stored == null) return false; + + String[] parts = stored.split("\\$"); + if (parts.length != 5) return false; + if (!"PBKDF2".equals(parts[0])) return false; + if (!"sha256".equalsIgnoreCase(parts[1])) return false; + + int it; + try { + it = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + return false; + } + + byte[] salt; + byte[] expected; + try { + salt = Hex.decode(parts[3]); + expected = Hex.decode(parts[4]); + } catch (Exception e) { + return false; + } + + byte[] actual = derive(password.toCharArray(), salt, it, expected.length); + return constantTimeEquals(expected, actual); + } + + private static byte[] derive(char[] password, byte[] salt, int iterations, int keyBytes) { + try { + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBytes * 8); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + return skf.generateSecret(spec).getEncoded(); + } catch (Exception e) { + throw new IllegalStateException("PBKDF2 not available", e); + } + } + + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a == null || b == null) return false; + if (a.length != b.length) return false; + int r = 0; + for (int i = 0; i < a.length; i++) r |= (a[i] ^ b[i]); + return r == 0; + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/RegistrarDao.java b/src/main/java/org/openautonomousconnection/oac2web/utils/RegistrarDao.java new file mode 100644 index 0000000..5b7ec4c --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/RegistrarDao.java @@ -0,0 +1,291 @@ +package org.openautonomousconnection.oac2web.utils; + +import java.sql.*; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * Data access for users and INS registrar data. + * + * Uses the provided oac_ins SQL schema: + * - users(id, uid, username, password) + * - infonames(id, info, tln_id, uid) + * - tln(id, name, info, owner_id, is_public, allow_subdomains) + * - subnames(id, name, infoname_id) + * - records(id, infoname_id, subname_id, type, value, ttl, priority, port, weight) + */ +public final class RegistrarDao { + + private final Oac2WebDb db; + + /** + * Creates the DAO. + * + * @param db db helper + */ + public RegistrarDao(Oac2WebDb db) { + this.db = Objects.requireNonNull(db, "db"); + } + + /** + * Creates a new user. + * + * @param usernameHash SHA-256 hex(username) + * @param passwordEncoded PBKDF2 storage string + * @return created user id + * @throws SQLException on error + */ + public int createUser(String usernameHash, String passwordEncoded) throws SQLException { + String sql = "INSERT INTO users(uid, username, password) VALUES(?,?,?)"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, usernameHash); + ps.setString(3, passwordEncoded); + ps.executeUpdate(); + + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) return rs.getInt(1); + } + throw new SQLException("No generated key for users"); + } + } + + /** + * Finds a user by username hash. + * + * @param usernameHash SHA-256 hex(username) + * @return optional user + * @throws SQLException on error + */ + public Optional findUserByUsernameHash(String usernameHash) throws SQLException { + String sql = "SELECT id, uid, username, password FROM users WHERE username = ?"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql)) { + + ps.setString(1, usernameHash); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return Optional.empty(); + return Optional.of(new UserRow( + rs.getInt("id"), + rs.getString("uid"), + rs.getString("username"), + rs.getString("password") + )); + } + } + } + + /** + * Resolves TLN id by name. + * + * @param tln TLN name + * @return optional id + * @throws SQLException on error + */ + public Optional findTlnId(String tln) throws SQLException { + String sql = "SELECT id FROM tln WHERE name = ?"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, tln); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return Optional.of(rs.getInt("id")); + return Optional.empty(); + } + } + } + + /** + * Creates a new infoname owned by a user. + * + * @param tlnId TLN id + * @param info info name (label) + * @param userId owner users.id + * @return infoname id + * @throws SQLException on error + */ + public int createInfoName(int tlnId, String info, int userId) throws SQLException { + String sql = "INSERT INTO infonames(info, tln_id, uid) VALUES(?,?,?)"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + + ps.setString(1, info); + ps.setInt(2, tlnId); + ps.setInt(3, userId); + ps.executeUpdate(); + + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) return rs.getInt(1); + } + throw new SQLException("No generated key for infonames"); + } + } + + /** + * Checks ownership (used for edit/delete only). + * + * @param infonameId infoname id + * @param userId owner users.id + * @return true if owned by user + * @throws SQLException on error + */ + public boolean isOwnerOfInfoName(int infonameId, int userId) throws SQLException { + String sql = "SELECT 1 FROM infonames WHERE id = ? AND uid = ?"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, infonameId); + ps.setInt(2, userId); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + /** + * Lists infonames owned by user. + * + * @param userId users.id + * @return result set as lightweight string table + * @throws SQLException on error + */ + public InfoNameRow[] listOwnedInfoNames(int userId) throws SQLException { + String sql = """ + SELECT i.id, i.info, t.name AS tln + FROM infonames i + JOIN tln t ON t.id = i.tln_id + WHERE i.uid = ? + ORDER BY t.name ASC, i.info ASC + """; + + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql)) { + + ps.setInt(1, userId); + + try (ResultSet rs = ps.executeQuery()) { + java.util.ArrayList out = new java.util.ArrayList<>(); + while (rs.next()) { + out.add(new InfoNameRow( + rs.getInt("id"), + rs.getString("tln"), + rs.getString("info") + )); + } + return out.toArray(new InfoNameRow[0]); + } + } + } + + /** + * Ensures a subname row exists (or null when sub is empty). + * + * @param infonameId infoname id + * @param sub sub label + * @return subname id or null + * @throws SQLException on error + */ + public Integer ensureSubname(int infonameId, String sub) throws SQLException { + if (sub == null || sub.isBlank()) return null; + + String find = "SELECT id FROM subnames WHERE infoname_id = ? AND name = ?"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(find)) { + + ps.setInt(1, infonameId); + ps.setString(2, sub); + + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return rs.getInt("id"); + } + } + + String ins = "INSERT INTO subnames(name, infoname_id) VALUES(?,?)"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(ins, Statement.RETURN_GENERATED_KEYS)) { + ps.setString(1, sub); + ps.setInt(2, infonameId); + ps.executeUpdate(); + + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) return rs.getInt(1); + } + throw new SQLException("No generated key for subnames"); + } + } + + /** + * Adds a record. + * + * @param infonameId infoname id + * @param subnameId subname id or null + * @param type record type enum string (A, AAAA, TXT, CNAME, MX, SRV, NS) + * @param value value + * @param ttl ttl + * @param priority priority + * @param port port + * @param weight weight + * @return record id + * @throws SQLException on error + */ + public int addRecord(int infonameId, Integer subnameId, String type, String value, int ttl, Integer priority, Integer port, Integer weight) throws SQLException { + String sql = "INSERT INTO records(infoname_id, subname_id, type, value, ttl, priority, port, weight) VALUES(?,?,?,?,?,?,?,?)"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + + ps.setInt(1, infonameId); + if (subnameId == null) ps.setNull(2, Types.INTEGER); + else ps.setInt(2, subnameId); + + ps.setString(3, type); + ps.setString(4, value); + ps.setInt(5, ttl); + + if (priority == null) ps.setNull(6, Types.INTEGER); else ps.setInt(6, priority); + if (port == null) ps.setNull(7, Types.INTEGER); else ps.setInt(7, port); + if (weight == null) ps.setNull(8, Types.INTEGER); else ps.setInt(8, weight); + + ps.executeUpdate(); + + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) return rs.getInt(1); + } + throw new SQLException("No generated key for records"); + } + } + + /** + * Deletes an infoname (CASCADE deletes subnames and records by schema). + * + * @param infonameId id + * @throws SQLException on error + */ + public void deleteInfoName(int infonameId) throws SQLException { + String sql = "DELETE FROM infonames WHERE id = ?"; + try (Connection c = db.open(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, infonameId); + ps.executeUpdate(); + } + } + + /** + * Lightweight row for users. + * + * @param id users.id + * @param uid users.uid + * @param usernameHash users.username (hashed) + * @param passwordEncoded users.password + */ + public record UserRow(int id, String uid, String usernameHash, String passwordEncoded) {} + + /** + * Lightweight row for owned infonames. + * + * @param id infonames.id + * @param tln tln.name + * @param info infonames.info + */ + public record InfoNameRow(int id, String tln, String info) {} +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/utils/Sha256.java b/src/main/java/org/openautonomousconnection/oac2web/utils/Sha256.java new file mode 100644 index 0000000..294bb89 --- /dev/null +++ b/src/main/java/org/openautonomousconnection/oac2web/utils/Sha256.java @@ -0,0 +1,29 @@ +package org.openautonomousconnection.oac2web.utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * Hash utilities. + */ +public final class Sha256 { + + private Sha256() {} + + /** + * Hashes text as SHA-256 (hex lowercase). + * + * @param text input + * @return hex sha256 + */ + public static String hex(String text) { + if (text == null) text = ""; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8)); + return Hex.lower(digest); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } +} diff --git a/src/main/java/org/openautonomousconnection/oac2web/wiki.java b/src/main/java/org/openautonomousconnection/oac2web/wiki.java index 4896090..ec8366e 100644 --- a/src/main/java/org/openautonomousconnection/oac2web/wiki.java +++ b/src/main/java/org/openautonomousconnection/oac2web/wiki.java @@ -1,32 +1,21 @@ package org.openautonomousconnection.oac2web; import org.openautonomousconnection.protocol.packets.v1_0_0.beta.web.WebResponsePacket; +import org.openautonomousconnection.webserver.api.Route; import org.openautonomousconnection.webserver.api.WebPage; import org.openautonomousconnection.webserver.api.WebPageContext; +import org.openautonomousconnection.webserver.utils.HttpsProxy; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Scanner; - +/** + * Proxies the HTTPS wiki page through OAC. + */ +@Route(path = "wiki.html") public class wiki implements WebPage { + @Override - public WebResponsePacket handle(WebPageContext webPageContext) throws Exception { - String content = null; - URLConnection connection = null; - - try { - connection = new URL("https://open-autonomous-connection.org/wiki.html").openConnection(); - Scanner scanner = new Scanner(connection.getInputStream()); - scanner.useDelimiter("\\Z"); - content = scanner.next(); - scanner.close(); - }catch ( Exception ex ) { - content = ex.getMessage(); - ex.printStackTrace(); - } - - return new WebResponsePacket(308, "text/html", new HashMap<>(), content.getBytes(StandardCharsets.UTF_8)); + public WebResponsePacket handle(WebPageContext webPageContext) { + if (webPageContext.request.getPath().equalsIgnoreCase("wiki.html")) + return HttpsProxy.proxyGet(webPageContext, "https://open-autonomous-connection.org/wiki.html"); + return null; } }