diff options
author | Trumeet <yuuta@yuuta.moe> | 2021-04-02 14:12:35 -0700 |
---|---|---|
committer | Trumeet <yuuta@yuuta.moe> | 2021-04-02 14:12:35 -0700 |
commit | 26417474f09eb042b2b8547c454f572086831b84 (patch) | |
tree | 4daf6bbef613ea82f2d43232c40e54ff9524f077 | |
parent | 74e4489d4736d714e22cf5c3893f1b12243a5a45 (diff) | |
download | dn42peering-26417474f09eb042b2b8547c454f572086831b84.tar dn42peering-26417474f09eb042b2b8547c454f572086831b84.tar.gz dn42peering-26417474f09eb042b2b8547c454f572086831b84.tar.bz2 dn42peering-26417474f09eb042b2b8547c454f572086831b84.zip |
feat(central): add nodes admin UI
15 files changed, 767 insertions, 33 deletions
diff --git a/central/src/main/java/moe/yuuta/dn42peering/admin/AdminHandler.java b/central/src/main/java/moe/yuuta/dn42peering/admin/AdminHandler.java index 4844913..766aae2 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/admin/AdminHandler.java +++ b/central/src/main/java/moe/yuuta/dn42peering/admin/AdminHandler.java @@ -20,15 +20,18 @@ import io.vertx.json.schema.SchemaParser; import io.vertx.json.schema.SchemaRouter; import io.vertx.json.schema.SchemaRouterOptions; import io.vertx.json.schema.common.dsl.ObjectSchemaBuilder; +import moe.yuuta.dn42peering.admin.nodes.NodeHandler; import moe.yuuta.dn42peering.asn.IASNService; import moe.yuuta.dn42peering.manage.AdminASNAuthProvider; +import moe.yuuta.dn42peering.node.INodeService; +import moe.yuuta.dn42peering.peer.IPeerService; import moe.yuuta.dn42peering.portal.ISubRouter; import javax.annotation.Nonnull; - import java.util.UUID; -import static io.vertx.json.schema.common.dsl.Schemas.*; +import static io.vertx.json.schema.common.dsl.Schemas.objectSchema; +import static io.vertx.json.schema.common.dsl.Schemas.stringSchema; public class AdminHandler implements ISubRouter { private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName()); @@ -37,6 +40,8 @@ public class AdminHandler implements ISubRouter { @Override public Router mount(@Nonnull Vertx vertx) { final IASNService asnService = IASNService.createProxy(vertx, IASNService.ADDRESS); + final INodeService nodeService = INodeService.createProxy(vertx); + final IPeerService peerService = IPeerService.createProxy(vertx); final TemplateEngine engine = FreeMarkerTemplateEngine.create(vertx, "ftlh"); final Router router = Router.router(vertx); @@ -53,7 +58,14 @@ public class AdminHandler implements ISubRouter { .produces("text/html") .handler(ctx -> { final String asn = ctx.user().principal().getString("username"); - AdminUI.renderIndex(engine, asn, ctx); + AdminUI.renderIndex( + engine, + asnService, + peerService, + nodeService, + asn, + ctx + ); }); router.get("/sudo") @@ -99,6 +111,7 @@ public class AdminHandler implements ISubRouter { } }); + router.mountSubRouter("/nodes", new NodeHandler().mount(vertx)); return router; } } diff --git a/central/src/main/java/moe/yuuta/dn42peering/admin/AdminUI.java b/central/src/main/java/moe/yuuta/dn42peering/admin/AdminUI.java index 59dbe08..b97a04c 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/admin/AdminUI.java +++ b/central/src/main/java/moe/yuuta/dn42peering/admin/AdminUI.java @@ -1,48 +1,76 @@ package moe.yuuta.dn42peering.admin; -import com.wireguard.crypto.Key; -import com.wireguard.crypto.KeyFormatException; -import edazdarevic.commons.net.CIDRUtils; -import io.vertx.core.AsyncResult; import io.vertx.core.Future; -import io.vertx.core.Handler; -import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.common.template.TemplateEngine; +import moe.yuuta.dn42peering.asn.IASNService; import moe.yuuta.dn42peering.node.INodeService; -import moe.yuuta.dn42peering.node.Node; -import moe.yuuta.dn42peering.peer.Peer; -import moe.yuuta.dn42peering.peer.ProvisionStatus; -import moe.yuuta.dn42peering.portal.FormException; -import org.apache.commons.validator.routines.InetAddressValidator; +import moe.yuuta.dn42peering.peer.IPeerService; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.io.IOException; -import java.net.Inet6Address; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import static moe.yuuta.dn42peering.portal.RenderingUtils.getGeneralRenderingHandler; class AdminUI { public static void renderIndex(@Nonnull TemplateEngine engine, + @Nonnull IASNService asnService, + @Nonnull IPeerService peerService, + @Nonnull INodeService nodeService, @Nonnull String asn, @Nonnull RoutingContext ctx) { - final Map<String, Object> root = new HashMap<>(); - root.put("asn", asn); - engine.render(root, "admin/index.ftlh", getGeneralRenderingHandler(ctx)); + Future.future(nodeService::listNodes) + .compose(nodes -> { + final Map<String, Object> root = new HashMap<>(); + root.put("asn", asn); + // Don't need to do that in async thread? + final JsonArray mapping = new JsonArray(); + nodes.stream().map(node -> { + final JsonObject nodeJson = new JsonObject(); + nodeJson.put("id", node.getId()); + nodeJson.put("name", node.getName()); + nodeJson.put("publicIp", node.getPublicIp()); + nodeJson.put("dn42IP4", node.getDn42Ip4()); + nodeJson.put("dn42IP6", node.getDn42Ip6()); + nodeJson.put("dn42IP6NonLL", node.getDn42Ip6NonLL()); + nodeJson.put("internalIP", node.getInternalIp()); + nodeJson.put("internalPort", node.getInternalPort()); + nodeJson.put("tunnelingMethods", node.getSupportedVPNTypes()); + return nodeJson; + }).forEach(mapping::add); + root.put("nodes", mapping); + return Future.succeededFuture(root); + }) + .compose(root -> { + return Future.future(asnService::count) + .compose(count -> { + root.put("asnTotal", count); + return Future.succeededFuture(root); + }); + }) + .compose(root -> { + return Future.future(asnService::count) + .compose(count -> { + root.put("peersTotal", count); + return Future.succeededFuture(root); + }); + }) + .compose(json -> { + return engine.render(json, "admin/index.ftlh"); + }) + .onComplete(getGeneralRenderingHandler(ctx)); } public static void renderSudo(@Nonnull TemplateEngine engine, - @Nonnull String asn, - @Nullable List<String> errors, - @Nullable String targetASN, - @Nonnull RoutingContext ctx) { + @Nonnull String asn, + @Nullable List<String> errors, + @Nullable String targetASN, + @Nonnull RoutingContext ctx) { final Map<String, Object> root = new HashMap<>(); root.put("asn", asn); root.put("errors", errors); diff --git a/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeAdminUI.java b/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeAdminUI.java new file mode 100644 index 0000000..dd625cc --- /dev/null +++ b/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeAdminUI.java @@ -0,0 +1,142 @@ +package moe.yuuta.dn42peering.admin.nodes; + +import edazdarevic.commons.net.CIDRUtils; +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.common.template.TemplateEngine; +import moe.yuuta.dn42peering.RPC; +import moe.yuuta.dn42peering.node.INodeService; +import moe.yuuta.dn42peering.node.Node; +import moe.yuuta.dn42peering.portal.FormException; +import moe.yuuta.dn42peering.portal.RenderingUtils; +import org.apache.commons.validator.routines.InetAddressValidator; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.net.Inet6Address; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class NodeAdminUI { + public static void renderForm(@Nonnull TemplateEngine engine, + @Nonnull INodeService nodeService, + @Nonnull String asn, + boolean newForm, + @Nullable Node node, + @Nullable List<String> errors, + @Nonnull RoutingContext ctx) { + final Map<String, Object> root = new HashMap<>(); + root.put("asn", asn); + root.put("tunneling_method_wireguard", true); + if (node != null) { + root.put("id", node.getId()); + root.put("input_asn", node.getAsn()); + root.put("name", node.getName()); + root.put("ipv4", node.getDn42Ip4()); + root.put("ipv6", node.getDn42Ip6()); + root.put("ipv6_non_ll", node.getDn42Ip6NonLL()); + root.put("public_ip", node.getPublicIp()); + root.put("internal_ip", node.getInternalIp()); + root.put("internal_port", node.getInternalPort()); + root.put("notice", node.getNotice()); + root.put("tunneling_method_wireguard", node.isWireguard()); + } + if(!newForm && node != null) + root.put("action", "/admin/nodes/edit?id=" + node.getId()); + else + root.put("action", "/admin/nodes/new"); + root.put("errors", errors); + engine.render(root, newForm ? "admin/nodes/new.ftlh" : "admin/nodes/edit.ftlh", + RenderingUtils.getGeneralRenderingHandler(ctx)); + } + + @Nonnull + public static Future<Node> parseForm(@Nonnull INodeService nodeService, + @Nonnull String authAsn, + @Nonnull JsonObject form) { + final List<String> errors = new ArrayList<>(10); + final Node node = new Node(); + node.setAsn(form.getString("asn", authAsn)); + node.setName(form.getString("name")); + node.setNotice(form.getString("notice")); + node.setDn42Ip4(form.getString("ipv4")); + node.setDn42Ip6(form.getString("ipv6")); + node.setDn42Ip6NonLL(form.getString("ipv6_non_ll")); + node.setInternalIp(form.getString("internal_ip")); + node.setPublicIp(form.getString("public_ip")); + try { + String raw = form.getString("internal_port"); + if(raw == null || raw.isEmpty()) { + node.setInternalPort(RPC.AGENT_PORT); + } else { + node.setInternalPort(Integer.parseInt(raw)); + } + } catch (NumberFormatException e) { + errors.add("Invalid internal port"); + } + node.setWireguard(form.containsKey("tunneling_method_wireguard")); + + if(!node.getAsn().matches("[aA][sS]424242[0-9][0-9][0-9][0-9]")) + errors.add("ASN is invalid"); + if(node.getName() == null || node.getName().trim().isEmpty()) + errors.add("No name supplied"); + try { + if(node.getDn42Ip4() != null) { + if (InetAddressValidator.getInstance().isValidInet4Address(node.getDn42Ip4())) { + if (!new CIDRUtils("172.20.0.0/14").isInRange(node.getDn42Ip4())) { + errors.add("DN42 IPv4 address is illegal. It must be a dn42 IPv4 address (172.20.x.x to 172.23.x.x)."); + } + } else + errors.add("DN42 IPv4 address is illegal. Cannot parse your address."); + } else { + errors.add("DN42 IPv4 address is not supplied"); + } + if(node.getDn42Ip6() != null) { + if (InetAddressValidator.getInstance().isValidInet6Address(node.getDn42Ip6())) { + if (new CIDRUtils("fd00::/8").isInRange(node.getDn42Ip6())) { + errors.add("DN42 IPv6 address is illegal. It must be a link-local IPv6 address."); + } + } else + errors.add("DN42 Link Local IPv6 address is illegal. Cannot parse your address."); + } else { + errors.add("DN42 IPv6 Link Local address is not supplied"); + } + if(node.getDn42Ip6NonLL() != null) { + if (InetAddressValidator.getInstance().isValidInet6Address(node.getDn42Ip6NonLL())) { + if (!new CIDRUtils("fd00::/8").isInRange(node.getDn42Ip6NonLL()) || + Inet6Address.getByName(node.getDn42Ip6NonLL()).isLinkLocalAddress()) { + errors.add("DN42 IPv6 address is illegal. It must be a dn42 IPv6 address."); + } + } else + errors.add("IPv6 address is illegal. Cannot parse your address."); + } else { + errors.add("DN42 IPv6 address is not supplied"); + } + } catch (IOException e) { + return Future.failedFuture(e); + } + + if(node.getInternalIp() == null) { + errors.add("Internal IP is not supplied."); + } + + if(node.getInternalPort() < 0 || + node.getInternalPort() > 65535) { + errors.add("Internal Port is out of range. Supported range: [0, 65535]."); + } + + if(node.getPublicIp() == null || node.getPublicIp().isEmpty()) { + errors.add("Public IP is not supplied."); + } + + if(errors.isEmpty()) { + return Future.succeededFuture(node); + } else { + return Future.failedFuture(new FormException(node, errors.toArray(new String[]{}))); + } + } +} diff --git a/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeHandler.java b/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeHandler.java new file mode 100644 index 0000000..a9e6f5b --- /dev/null +++ b/central/src/main/java/moe/yuuta/dn42peering/admin/nodes/NodeHandler.java @@ -0,0 +1,262 @@ +package moe.yuuta.dn42peering.admin.nodes; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.impl.logging.Logger; +import io.vertx.core.impl.logging.LoggerFactory; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.common.template.TemplateEngine; +import io.vertx.ext.web.handler.BodyHandler; +import io.vertx.ext.web.templ.freemarker.FreeMarkerTemplateEngine; +import io.vertx.ext.web.validation.RequestParameters; +import io.vertx.ext.web.validation.RequestPredicate; +import io.vertx.ext.web.validation.ValidationHandler; +import io.vertx.ext.web.validation.builder.Bodies; +import io.vertx.json.schema.SchemaParser; +import io.vertx.json.schema.SchemaRouter; +import io.vertx.json.schema.SchemaRouterOptions; +import io.vertx.json.schema.common.dsl.ObjectSchemaBuilder; +import io.vertx.serviceproxy.ServiceException; +import moe.yuuta.dn42peering.asn.IASNService; +import moe.yuuta.dn42peering.jaba.Pair; +import moe.yuuta.dn42peering.node.DuplicateNodeException; +import moe.yuuta.dn42peering.node.INodeService; +import moe.yuuta.dn42peering.node.Node; +import moe.yuuta.dn42peering.peer.IPeerService; +import moe.yuuta.dn42peering.portal.FormException; +import moe.yuuta.dn42peering.portal.HTTPException; +import moe.yuuta.dn42peering.portal.ISubRouter; +import moe.yuuta.dn42peering.provision.IProvisionRemoteService; + +import javax.annotation.Nonnull; +import java.util.Arrays; + +import static io.vertx.ext.web.validation.builder.Parameters.param; +import static io.vertx.json.schema.common.dsl.Schemas.objectSchema; +import static io.vertx.json.schema.common.dsl.Schemas.stringSchema; + +public class NodeHandler implements ISubRouter { + private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName()); + + @Nonnull + @Override + public Router mount(@Nonnull Vertx vertx) { + final IASNService asnService = IASNService.createProxy(vertx, IASNService.ADDRESS); + final INodeService nodeService = INodeService.createProxy(vertx); + final IPeerService peerService = IPeerService.createProxy(vertx); + final IProvisionRemoteService provisionRemoteService = IProvisionRemoteService.create(vertx); + final TemplateEngine engine = FreeMarkerTemplateEngine.create(vertx, "ftlh"); + final SchemaParser parser = SchemaParser.createDraft7SchemaParser( + SchemaRouter.create(vertx, new SchemaRouterOptions())); + + final ObjectSchemaBuilder nodeSchema = objectSchema() + .allowAdditionalProperties(false) + .property("asn", stringSchema()) + .property("name", stringSchema()) + .property("notice", stringSchema()) + .property("ipv4", stringSchema()) + .property("ipv6", stringSchema()) + .property("ipv6_non_ll", stringSchema()) + .property("public_ip", stringSchema()) + .property("internal_ip", stringSchema()) + .property("internal_port", stringSchema()) + .property("tunneling_method_wireguard", stringSchema()); + + final Router router = Router.router(vertx); + router.post().handler(BodyHandler.create().setBodyLimit(100 * 1024)); + + router.get("/new") + .produces("text/html") + .handler(ctx -> NodeAdminUI.renderForm(engine, + nodeService, + ctx.user().principal().getString("username"), + true, + null, + null, + ctx)); + + router.post("/new") + .handler(BodyHandler.create().setBodyLimit(100 * 1024)) + .handler(ValidationHandler + .builder(parser) + .body(Bodies.formUrlEncoded(nodeSchema)) + .predicate(RequestPredicate.BODY_REQUIRED) + .build()) + .handler(ctx -> { + final String asn = ctx.user().principal().getString("username"); + final JsonObject parameters = ctx.<RequestParameters>get(ValidationHandler.REQUEST_CONTEXT_KEY) + .body().getJsonObject(); + NodeAdminUI.parseForm(nodeService, asn, parameters) + .<Node>compose(node -> Future.future(f -> nodeService.addNew(node, ar -> { + if (ar.succeeded()) { + node.setId((int) (long) ar.result()); + f.complete(node); + } else { + if(((ServiceException)ar.cause()).getDebugInfo().getString("causeName") + .equals(DuplicateNodeException.class.getName())) { + f.fail(new FormException(node, "A node with your given public IP already exists.")); + return; + } + f.fail(ar.cause()); + } + }))) + .onSuccess(peer -> { + ctx.response() + .setStatusCode(303) + .putHeader("Location", "/admin") + .end(); + }) + .onFailure(err -> { + if (err instanceof FormException) { + NodeAdminUI.renderForm(engine, nodeService, + asn, + true, + ((Node) ((FormException) err).data), + Arrays.asList(((FormException) err).errors), + ctx); + } else { + if (!(err instanceof HTTPException)) logger.error("Cannot add node.", err); + ctx.fail(err); + } + }); + }); + + router.get("/edit") + .produces("text/html") + .handler(ValidationHandler + .builder(parser) + .queryParameter(param("id", stringSchema())) + .build()) + .handler(ctx -> { + final String asn = ctx.user().principal().getString("username"); + final String id = ctx.<RequestParameters>get(ValidationHandler.REQUEST_CONTEXT_KEY) + .queryParameter("id").getString(); + int intId; + try { + intId = Integer.parseInt(id); + } catch (NumberFormatException ignored) { + ctx.fail(new HTTPException(400)); + return; + } + Future.<Node>future(f -> nodeService.getNode(intId, f)) + .compose(node -> { + if (node == null) { + return Future.failedFuture(new HTTPException(400)); + } else { + return Future.succeededFuture(node); + } + }) + .onSuccess(node -> + NodeAdminUI.renderForm( + engine, + nodeService, + asn, + false, + node, + null, + ctx)) + .onFailure(ctx::fail); + }); + + router.post("/edit") + .handler(BodyHandler.create().setBodyLimit(100 * 1024)) + .handler(ValidationHandler + .builder(parser) + .body(Bodies.formUrlEncoded(nodeSchema)) + .queryParameter(param("id", stringSchema())) + .predicate(RequestPredicate.BODY_REQUIRED) + .build()) + .handler(ctx -> { + final String asn = ctx.user().principal().getString("username"); + final JsonObject parameters = ctx.<RequestParameters>get(ValidationHandler.REQUEST_CONTEXT_KEY) + .body().getJsonObject(); + final String id = ctx.<RequestParameters>get(ValidationHandler.REQUEST_CONTEXT_KEY) + .queryParameter("id").getString(); + int intId; + try { + intId = Integer.parseInt(id); + } catch (NumberFormatException ignored) { + ctx.response().setStatusCode(400).end(); + return; + } + Future.<Node>future(f -> nodeService.getNode(intId, f)) + .compose(node -> { + if(node == null) { + return Future.failedFuture(new HTTPException(404)); + } + return Future.succeededFuture(node); + }) + .compose(existingNode -> { + return NodeAdminUI.parseForm(nodeService, asn, parameters) + .compose(newNode -> { + newNode.setId(existingNode.getId()); + return Future.succeededFuture(new Pair<>(existingNode, newNode)); + }); + }) + .<Node>compose(node -> Future.future(f -> nodeService.updateTo(node.b, ar -> { + if (ar.succeeded()) { + f.complete(node.b); + } else { + if(((ServiceException)ar.cause()).getDebugInfo().getString("causeName") + .equals(DuplicateNodeException.class.getName())) { + f.fail(new FormException(node.b, "A node with your given public IP already exists.")); + return; + } + f.fail(ar.cause()); + } + }))) + .onSuccess(node -> { + ctx.response() + .setStatusCode(303) + .putHeader("Location", "/admin") + .end(); + provisionRemoteService.deploy(node.getId(), ar -> {}); + }) + .onFailure(err -> { + if (err instanceof FormException) { + NodeAdminUI.renderForm(engine, nodeService, + asn, + true, + ((Node) ((FormException) err).data), + Arrays.asList(((FormException) err).errors), + ctx); + } else { + if (!(err instanceof HTTPException)) logger.error("Cannot add node.", err); + ctx.fail(err); + } + }); + }); + + router.get("/delete") + .handler(BodyHandler.create().setBodyLimit(100 * 1024)) + .handler(ValidationHandler + .builder(parser) + .queryParameter(param("id", stringSchema())) + .build()) + .handler(ctx -> { + final String id = ctx.<RequestParameters>get(ValidationHandler.REQUEST_CONTEXT_KEY) + .queryParameter("id").getString(); + int intId; + try { + intId = Integer.parseInt(id); + } catch (NumberFormatException ignored) { + ctx.response().setStatusCode(400).end(); + return; + } + nodeService.delete(intId, ar -> { + if(ar.succeeded()) { + ctx.response() + .setStatusCode(303) + .putHeader("Location", "/admin") + .end(); + } else { + logger.error("Cannot delete node " + intId, ar.cause()); + ctx.fail(ar.cause()); + } + }); + }); + + return router; + } +} diff --git a/central/src/main/java/moe/yuuta/dn42peering/asn/ASNServiceImpl.java b/central/src/main/java/moe/yuuta/dn42peering/asn/ASNServiceImpl.java index 20cf3ce..2fce4f1 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/asn/ASNServiceImpl.java +++ b/central/src/main/java/moe/yuuta/dn42peering/asn/ASNServiceImpl.java @@ -151,4 +151,15 @@ class ASNServiceImpl implements IASNService { .onComplete(handler); return this; } + + @Nonnull + @Override + public IASNService count(@Nonnull Handler<AsyncResult<Integer>> handler) { + SqlTemplate + .forQuery(pool, "SELECT COUNT(asn) FROM asn") + .execute(null) + .compose(rows -> Future.succeededFuture(rows.iterator().next().getInteger(0))) + .onComplete(handler); + return this; + } } diff --git a/central/src/main/java/moe/yuuta/dn42peering/asn/IASNService.java b/central/src/main/java/moe/yuuta/dn42peering/asn/IASNService.java index 5da2a57..a8f27da 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/asn/IASNService.java +++ b/central/src/main/java/moe/yuuta/dn42peering/asn/IASNService.java @@ -49,4 +49,8 @@ public interface IASNService { @Fluent @Nonnull IASNService lookupEmails(@Nonnull WhoisObject asn, @Nonnull Handler<AsyncResult<List<String>>> handler); + + @Fluent + @Nonnull + IASNService count(@Nonnull Handler<AsyncResult<Integer>> handler); } diff --git a/central/src/main/java/moe/yuuta/dn42peering/node/DuplicateNodeException.java b/central/src/main/java/moe/yuuta/dn42peering/node/DuplicateNodeException.java new file mode 100644 index 0000000..d32cddb --- /dev/null +++ b/central/src/main/java/moe/yuuta/dn42peering/node/DuplicateNodeException.java @@ -0,0 +1,4 @@ +package moe.yuuta.dn42peering.node; + +public class DuplicateNodeException extends Exception { +} diff --git a/central/src/main/java/moe/yuuta/dn42peering/node/INodeService.java b/central/src/main/java/moe/yuuta/dn42peering/node/INodeService.java index 9323faa..a7a5016 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/node/INodeService.java +++ b/central/src/main/java/moe/yuuta/dn42peering/node/INodeService.java @@ -25,4 +25,16 @@ public interface INodeService { @Fluent @Nonnull INodeService getNode(int id, @Nonnull Handler<AsyncResult<Node>> handler); + + @Fluent + @Nonnull + INodeService addNew(@Nonnull Node node, @Nonnull Handler<AsyncResult<Long>> handler); + + @Fluent + @Nonnull + INodeService updateTo(@Nonnull Node node, @Nonnull Handler<AsyncResult<Long>> handler); + + @Fluent + @Nonnull + INodeService delete(int id, @Nonnull Handler<AsyncResult<Void>> handler); } diff --git a/central/src/main/java/moe/yuuta/dn42peering/node/NodeServiceImpl.java b/central/src/main/java/moe/yuuta/dn42peering/node/NodeServiceImpl.java index a2535d4..bc7e5bf 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/node/NodeServiceImpl.java +++ b/central/src/main/java/moe/yuuta/dn42peering/node/NodeServiceImpl.java @@ -4,15 +4,15 @@ import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; +import io.vertx.mysqlclient.MySQLException; import io.vertx.sqlclient.Pool; import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.SqlResult; import io.vertx.sqlclient.templates.SqlTemplate; import moe.yuuta.dn42peering.database.DatabaseUtils; import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; class NodeServiceImpl implements INodeService { private final Pool pool; @@ -54,7 +54,7 @@ class NodeServiceImpl implements INodeService { .mapTo(NodeRowMapper.INSTANCE) .execute(Collections.singletonMap("id", id)) .compose(nodeRowMappers -> { - if(nodeRowMappers.iterator().hasNext()) { + if (nodeRowMappers.iterator().hasNext()) { return Future.succeededFuture(nodeRowMappers.iterator().next()); } else { return Future.succeededFuture(null); @@ -63,4 +63,88 @@ class NodeServiceImpl implements INodeService { .onComplete(handler); return this; } + + @Nonnull + @Override + public INodeService addNew(@Nonnull Node node, + @Nonnull Handler<AsyncResult<Long>> handler) { + node.setId(0); + Future.<Long>future(f -> { + Future.<RowSet<Node>>future(f1 -> SqlTemplate + .forUpdate(pool, "INSERT INTO node (id, asn, name, notice, " + + "public_ip, " + + "dn42_ip4, dn42_ip6, dn42_ip6_nonll," + + "internal_ip, internal_port," + + "vpn_type_wg) " + + "VALUES (#{id}, #{asn}, #{name}, #{notice}, " + + "#{public_ip}, " + + "#{dn42_ip4}, #{dn42_ip6}, #{dn42_ip6_nonll}, " + + "#{internal_ip}, #{internal_port}, " + + "#{vpn_type_wg}" + + ")") + .mapFrom(NodeParametersMapper.INSTANCE) + .mapTo(NodeRowMapper.INSTANCE) + .execute(node, f1)) + .compose(rows -> Future.succeededFuture(rows.property(DatabaseUtils.LAST_INSERTED_ID))) + .onFailure(err -> { + if (err instanceof MySQLException) { + if (((MySQLException) err).getErrorCode() == 1062 /* Duplicate */) { + f.fail(new DuplicateNodeException()); + return; + } + f.fail(err); + } + }) + .onSuccess(f::complete); + }).onComplete(handler); + return this; + } + + @Nonnull + @Override + public INodeService updateTo(@Nonnull Node node, @Nonnull Handler<AsyncResult<Long>> handler) { + Future.<Long>future(f -> { + Future.<RowSet<Node>>future(f1 -> SqlTemplate + .forUpdate(pool, "UPDATE node SET " + + "asn = #{asn}," + + "name = #{name}," + + "notice = #{notice}," + + "public_ip = #{public_ip}," + + "dn42_ip4 = #{dn42_ip4}," + + "dn42_ip6 = #{dn42_ip6}," + + "dn42_ip6_nonll = #{dn42_ip6_nonll}," + + "internal_ip = #{internal_ip}," + + "internal_port = #{internal_port}," + + "vpn_type_wg = #{vpn_type_wg} " + + "WHERE id = #{id}") + .mapFrom(NodeParametersMapper.INSTANCE) + .mapTo(NodeRowMapper.INSTANCE) + .execute(node, f1)) + .compose(rows -> Future.succeededFuture(rows.property(DatabaseUtils.LAST_INSERTED_ID))) + .onFailure(err -> { + if (err instanceof MySQLException) { + if (((MySQLException) err).getErrorCode() == 1062 /* Duplicate */) { + f.fail(new DuplicateNodeException()); + return; + } + f.fail(err); + } + }) + .onSuccess(f::complete); + }).onComplete(handler); + return this; + } + + @Nonnull + @Override + public INodeService delete(int id, @Nonnull Handler<AsyncResult<Void>> handler) { + final Map<String, Object> params = new HashMap<>(2); + params.put("id", id); + Future.<SqlResult<Void>>future(f -> SqlTemplate + .forUpdate(pool, "DELETE FROM node WHERE id = #{id}") + .execute(params, f)) + .<Void>compose(voidSqlResult -> Future.succeededFuture(null)) + .onComplete(handler); + return this; + } } diff --git a/central/src/main/java/moe/yuuta/dn42peering/peer/IPeerService.java b/central/src/main/java/moe/yuuta/dn42peering/peer/IPeerService.java index 5f4a7af..67240e0 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/peer/IPeerService.java +++ b/central/src/main/java/moe/yuuta/dn42peering/peer/IPeerService.java @@ -50,4 +50,13 @@ public interface IPeerService { @Fluent @Nonnull IPeerService changeProvisionStatus(int id, @Nonnull ProvisionStatus provisionStatus, @Nonnull Handler<AsyncResult<Void>> handler); + + @Fluent + @Nonnull + IPeerService count(@Nonnull Handler<AsyncResult<Integer>> handler); + + @Fluent + @Nonnull + IPeerService listUnderNode(long nodeId, + @Nonnull Handler<AsyncResult<List<Peer>>> handler); } diff --git a/central/src/main/java/moe/yuuta/dn42peering/peer/PeerServiceImpl.java b/central/src/main/java/moe/yuuta/dn42peering/peer/PeerServiceImpl.java index 9548815..d202882 100644 --- a/central/src/main/java/moe/yuuta/dn42peering/peer/PeerServiceImpl.java +++ b/central/src/main/java/moe/yuuta/dn42peering/peer/PeerServiceImpl.java @@ -139,7 +139,7 @@ class PeerServiceImpl implements IPeerService { .execute(params, f)) .compose(rows -> Future.succeededFuture(rows.iterator().next().getInteger(0) > 0))); } - if(futures.isEmpty()) { + if (futures.isEmpty()) { Future.succeededFuture(false).onComplete(handler); return this; } @@ -173,7 +173,7 @@ class PeerServiceImpl implements IPeerService { .mapTo(PeerRowMapper.INSTANCE) .execute(params, f)) .compose(peers -> { - if(peers.iterator().hasNext()) + if (peers.iterator().hasNext()) return Future.succeededFuture(peers.iterator().next()); return Future.succeededFuture(null); }) @@ -190,8 +190,8 @@ class PeerServiceImpl implements IPeerService { Future.<SqlResult<Void>>future(f -> SqlTemplate .forUpdate(pool, "DELETE FROM peer WHERE id = #{id} AND asn = #{asn}") .execute(params, f)) - .<Void>compose(voidSqlResult -> Future.succeededFuture(null)) - .onComplete(handler); + .<Void>compose(voidSqlResult -> Future.succeededFuture(null)) + .onComplete(handler); return this; } @@ -208,4 +208,35 @@ class PeerServiceImpl implements IPeerService { .onComplete(handler); return this; } + + @Nonnull + @Override + public IPeerService count(@Nonnull Handler<AsyncResult<Integer>> handler) { + SqlTemplate + .forQuery(pool, "SELECT COUNT(id) FROM peer") + .execute(null) + .compose(rows -> Future.succeededFuture(rows.iterator().next().getInteger(0))) + .onComplete(handler); + return this; + } + + @Nonnull + @Override + public IPeerService listUnderNode(long nodeId, @Nonnull Handler<AsyncResult<List<Peer>>> handler) { + SqlTemplate + .forQuery(pool, "SELECT id, type, asn, ipv4, ipv6, " + + "wg_endpoint, wg_endpoint_port, " + + "wg_self_pubkey, wg_self_privkey, wg_peer_pubkey, wg_preshared_secret, " + + "provision_status, mpbgp, node FROM peer " + + "WHERE node = #{node}") + .mapTo(PeerRowMapper.INSTANCE) + .execute(Collections.singletonMap("node", nodeId)) + .compose(peers -> { + final List<Peer> peerList = new ArrayList<>(); + for (Peer peer : peers) peerList.add(peer); + return Future.succeededFuture(peerList); + }) + .onComplete(handler); + return this; + } } diff --git a/central/src/main/resources/admin/index.ftlh b/central/src/main/resources/admin/index.ftlh index fa6e28f..3316622 100644 --- a/central/src/main/resources/admin/index.ftlh +++ b/central/src/main/resources/admin/index.ftlh @@ -12,5 +12,34 @@ <ul> <li><a href="/admin/sudo">sudo</a></li> </ul> +<h2>Registered ASNs and Peering</h2> +<p>Total ASN: ${asnTotal}</p> +<p>Total peers: ${peersTotal}</p> +<h2>Nodes</h2> +<a href="/admin/nodes/new">New Node</a> +<table style="width: 100%"> + <tr> + <th>Name</th> + <th>Public IP</th> + <th>DN42 IP</th> + <th>Internal IP</th> + <th>Tunnel Methods</th> + <th>Actions</th> + </tr> + <#list nodes as node> + <tr> + <td>${node.name}</td> + <td>${node.publicIp}</td> + <td>${node.dn42IP4}<br />${node.dn42IP6}<br />${node.dn42IP6NonLL}</td> + <td>${node.internalIP}:${node.internalPort?long?c}</td> + <td> + <#list node.tunnelingMethods as method> + ${method}<br /> + </#list> + </td> + <td><a href="/admin/nodes/edit?id=${node.id}">Edit</a></td> + </tr> + </#list> +</table> </body> </html>
\ No newline at end of file diff --git a/central/src/main/resources/admin/nodes/edit.ftlh b/central/src/main/resources/admin/nodes/edit.ftlh new file mode 100644 index 0000000..6c0c8dc --- /dev/null +++ b/central/src/main/resources/admin/nodes/edit.ftlh @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <#include "/style.ftlh"> + <title>Edit Node | Admin</title> +</head> +<body class="markdown-body"> +<h1>Edit Node</h1> +<p>You are logged in as: ${asn}.</p> +<p><b>Note: If you are switching to a new machine and had changed the internal IP, configurations on the old machine will not be removed. The new machine will be provisioned as usual.</b></p> +<#include "form.ftlh"> +<h2>More Actions</h2> +<a href="/admin/nodes/delete?id=${id}">Delete</a> +</body> +</html>
\ No newline at end of file diff --git a/central/src/main/resources/admin/nodes/form.ftlh b/central/src/main/resources/admin/nodes/form.ftlh new file mode 100644 index 0000000..43b7f3d --- /dev/null +++ b/central/src/main/resources/admin/nodes/form.ftlh @@ -0,0 +1,76 @@ +<#if errors??> +<div> + <p style="color:red">Errors in the previous form:</p> + <ul> + <#list errors as error> + <li>${error}</li> + </#list> + </ul> +</div> +</#if> +<form action="${action}" method="post"> + <label>Basic Information</label><br /> + <label for="asn">ASN:</label><br /> + <input type="text" id="asn" name="asn" + placeholder="${asn}" + pattern="[aA][sS]424242[0-9][0-9][0-9][0-9]" + value="${(input_asn)!}"><br /> + <br /> + <label for="name">Name:</label><br /> + <input type="text" id="name" name="name" required + placeholder="My Awesome Node" + value="${(name)!}"><br /> + <br /> + <label for="notice">Notice (Optional, HTML enabled):</label><br /> + <input type="text" id="notice" name="notice" + placeholder="<b>Hi!</b>" + value="${(notice)!}"><br /> + <br /> + + <label>DN42 Network Information</label><br /> + <label for="ipv4">DN42 IPv4 Address:</label><br /> + <input type="text" id="ipv4" name="ipv4" required + placeholder="172.22.114.10" + pattern="^172\.2[0-3](\.([1-9]?\d|[12]\d\d)){2}$" + value="${(ipv4)!}"><br /> + <br /> + <label for="ipv6">Link Local IPv6 Address:</label><br /> + <input type="text" id="ipv6" name="ipv6" required + placeholder="fe80::2980" + value="${(ipv6)!}"><br /> + <br /> + <label for="ipv6_non_ll">DN42 IPv6 Address:</label><br /> + <input type="text" id="ipv6_non_ll" name="ipv6_non_ll" required + placeholder="fd3f:a1f1:54ed::1" + value="${(ipv6_non_ll)!}"><br /> + <br /> + + <label>Public Network Information</label><br /> + <label for="public_ip">Public IP or Domain (No validation. For end user display only.):</label><br /> + <input type="text" id="public_ip" name="public_ip" required + placeholder="tyo1.jp.dn42.yuuta.moe" + value="${(public_ip)!}"><br /> + <br /> + + <label>Management Network Information</label><br /> + <label for="internal_ip">Internal IP or domain (No validation. Type with care.):</label><br /> + <input type="text" id="internal_ip" name="internal_ip" required + placeholder="192.168.10.1" + value="${(internal_ip)!}"><br /> + <br /> + <label for="internal_port">Internal Port:</label><br /> + <input type="text" id="internal_port" name="internal_port" + placeholder="49200" + pattern="[0-9]+" + value="${(internal_port?long?c)!}"><br /> + <br /> + + <label>Tunneling Methods</label><br /> + <input type="checkbox" id="tunneling_method_wireguard" + name="tunneling_method_wireguard" + value="tunneling_method_wireguard" + ${tunneling_method_wireguard?string('checked', '')}> + <label for="tunneling_method_wireguard">Support WireGuard</label><br /><br /> + + <input type="submit"> +</form>
\ No newline at end of file diff --git a/central/src/main/resources/admin/nodes/new.ftlh b/central/src/main/resources/admin/nodes/new.ftlh new file mode 100644 index 0000000..30c2524 --- /dev/null +++ b/central/src/main/resources/admin/nodes/new.ftlh @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <#include "/style.ftlh"> + <title>Add New Node | Admin</title> +</head> +<body class="markdown-body"> +<h1>Add New Node</h1> +<p>You are logged in as: ${asn}.</p> +<#include "form.ftlh"> +</body> +</html>
\ No newline at end of file |