From 811b163e5af872bef427126ac83556b13bff7784 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Mon, 18 May 2026 12:28:27 +0100 Subject: [PATCH] Ready for production adds missing endpoints --- .../vpnorchestrator/vps/VpsController.java | 77 +++- .../vpnorchestrator/vps/WireGuardService.java | 397 +++++++++++++++++- .../dto/ControllerClientCreateRequest.java | 6 + .../vps/dto/ControllerClientFileResponse.java | 5 + .../ControllerClientFileUpdateRequest.java | 5 + .../vps/dto/ControllerClientSummary.java | 10 + .../vps/dto/ControllerClientsResponse.java | 7 + .../dto/DeleteControllerClientRequest.java | 5 + .../vps/dto/RawRouterFirewallResponse.java | 7 + .../vps/dto/RouterDnatRule.java | 13 + .../dto/RouterFirewallRuleDeleteRequest.java | 7 + .../dto/RouterFirewallRuleWriteRequest.java | 14 + .../dto/RouterFirewallRuleWriteResponse.java | 9 + .../vps/dto/RouterFirewallRulesRequest.java | 6 + .../vps/dto/RouterFirewallRulesResponse.java | 10 + 15 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientCreateRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileUpdateRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientSummary.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientsResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/DeleteControllerClientRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RawRouterFirewallResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterDnatRule.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleDeleteRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteResponse.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesRequest.java create mode 100644 src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesResponse.java diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java index bbec791..31d2fcf 100644 --- a/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/VpsController.java @@ -1,7 +1,6 @@ package com.litoralregas.vpnorchestrator.vps; -import com.litoralregas.vpnorchestrator.vps.dto.NetworkTrafficResponse; -import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse; +import com.litoralregas.vpnorchestrator.vps.dto.*; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; @@ -37,4 +36,78 @@ public class VpsController { public NetworkTrafficResponse getUdp2rawTraffic() { return wireGuardService.getUdp2rawTraffic(); } + + @GetMapping( + value = "/controllers/clients", + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ControllerClientsResponse getControllerClients() { + return wireGuardService.getControllerClients(); + } + + @GetMapping("/controllers/clients/{clientId}") + public ControllerClientFileResponse getControllerClientFile( + @PathVariable String clientId + ) { + return wireGuardService.getControllerClientFile(clientId); + } + + @PostMapping("/controllers/clients/{clientId}") + public ControllerClientFileResponse updateControllerClientFile( + @PathVariable String clientId, + @RequestBody ControllerClientFileUpdateRequest request + ) { + return wireGuardService.updateControllerClientFile( + clientId, + request.content() + ); + } + + @PostMapping("/controllers/clients") + public ControllerClientFileResponse createControllerClient( + @RequestBody ControllerClientCreateRequest request + ) { + return wireGuardService.createControllerClient( + request.name(), + request.password() + ); + } + + @DeleteMapping("controllers/clients") + public ControllerClientFileResponse deleteControllerClient( + @RequestBody DeleteControllerClientRequest request + ) { + return wireGuardService.deleteControllerClient(request.clientId()); + } + + @PostMapping("/controllers/routers/firewall/rules") + public RouterFirewallRulesResponse getRouterFirewallRules( + @RequestBody RouterFirewallRulesRequest request + ) { + return wireGuardService.getRouterFirewallRules( + request.routerHost(), + request.routerPassword() + ); + } + + @PostMapping("/controllers/routers/firewall/rules/create") + public RouterFirewallRuleWriteResponse createRouterFirewallRule( + @RequestBody RouterFirewallRuleWriteRequest request + ) { + return wireGuardService.createRouterFirewallRule(request); + } + + @PostMapping("/controllers/routers/firewall/rules/update") + public RouterFirewallRuleWriteResponse updateRouterFirewallRule( + @RequestBody RouterFirewallRuleWriteRequest request + ) { + return wireGuardService.updateRouterFirewallRule(request); + } + + @DeleteMapping("/controllers/routers/firewall/rules") + public RouterFirewallRuleWriteResponse deleteRouterFirewallRule( + @RequestBody RouterFirewallRuleDeleteRequest request + ) { + return wireGuardService.deleteRouterFirewallRule(request); + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java index d672d2e..1fde41e 100644 --- a/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/WireGuardService.java @@ -2,12 +2,10 @@ package com.litoralregas.vpnorchestrator.vps; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.litoralregas.vpnorchestrator.vps.dto.NetworkTrafficResponse; -import com.litoralregas.vpnorchestrator.vps.dto.VpsHealthResponse; +import com.litoralregas.vpnorchestrator.vps.dto.*; import org.springframework.stereotype.Service; -import java.util.HashSet; -import java.util.Set; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -217,4 +215,395 @@ public class WireGuardService { ); } } + + public ControllerClientsResponse getControllerClients() { + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-controllers-list" + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to list controller clients: " + result.stderr() + ); + } + + try { + return objectMapper.readValue( + result.stdout(), + ControllerClientsResponse.class + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid controller clients JSON returned by script", + e + ); + } + } + + public ControllerClientFileResponse getControllerClientFile(String clientId) { + String safeClientId = validateControllerClientId(clientId); + + SshCommandResult result = sshService.executeOnConfiguredVps( + "sudo /usr/local/sbin/lr-controller-client-read " + shellQuote(safeClientId) + ); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to read controller client file: " + result.stderr() + ); + } + + try { + return objectMapper.readValue( + result.stdout(), + ControllerClientFileResponse.class + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid controller client file JSON returned by script", + e + ); + } + } + + private String validateControllerClientId(String clientId) { + if (clientId == null || !clientId.matches("^[A-Za-z0-9_-]+$")) { + throw new IllegalArgumentException("Invalid controller client id"); + } + + return clientId; + } + + public ControllerClientFileResponse updateControllerClientFile( + String clientId, + String content + ) { + String safeClientId = validateControllerClientId(clientId); + + if (content == null || content.isBlank()) { + throw new IllegalArgumentException("Content is required"); + } + + if (content.contains("\0")) { + throw new IllegalArgumentException("Content contains invalid characters"); + } + + String command = "sudo /usr/local/sbin/lr-controller-client-write " + + shellQuote(safeClientId) + " " + + shellQuote(content); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to update controller client file: " + + result.stderr() + + result.stdout() + ); + } + + try { + return objectMapper.readValue( + result.stdout(), + ControllerClientFileResponse.class + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid controller client write JSON returned by script", + e + ); + } + } + + public ControllerClientFileResponse createControllerClient( + String name, + String password + ) { + String safeName = validateNewControllerClientName(name); + String safePassword = validateControllerClientPassword(password); + + String command = "sudo /usr/local/sbin/lr-controller-client-create " + + shellQuote(safeName) + " " + + shellQuote(safePassword); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to create controller client file: " + result.stderr() + ); + } + + try { + return objectMapper.readValue( + result.stdout(), + ControllerClientFileResponse.class + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid controller client create JSON returned by script", + e + ); + } + } + + private String validateNewControllerClientName(String name) { + if (name == null) { + throw new IllegalArgumentException("Client name is required"); + } + + String trimmed = name.trim(); + + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("Client name is required"); + } + + if (!trimmed.matches("^[A-Za-z0-9 _-]+$")) { + throw new IllegalArgumentException( + "Client name can only contain letters, numbers, spaces, underscores and hyphens" + ); + } + + return trimmed; + } + + private String validateControllerClientPassword(String password) { + if (password == null || password.trim().isEmpty()) { + throw new IllegalArgumentException("Client password is required"); + } + + String trimmed = password.trim(); + + if (trimmed.contains("xxx") || trimmed.contains("\n") || trimmed.contains("\r")) { + throw new IllegalArgumentException("Client password contains invalid characters"); + } + + return trimmed; + } + + public ControllerClientFileResponse deleteControllerClient(String clientId) { + if (clientId.isEmpty()) throw new IllegalArgumentException("Client id is required"); + + String command = "sudo /usr/local/sbin/lr-controller-client-delete " + + shellQuote(clientId); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to delete controller client file: " + result.stderr() + ); + } + + try { + return objectMapper.readValue( + result.stdout(), + ControllerClientFileResponse.class + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid controller client delete JSON returned by script", + e + ); + } + } + + public RouterFirewallRulesResponse getRouterFirewallRules( + String routerHost, + String routerPassword + ) { + String command = "sudo /usr/local/sbin/lr-router-firewall-show " + + shellQuote(routerHost) + " " + + shellQuote(routerPassword); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to read router firewall rules: " + result.stderr() + ); + } + + try { + RawRouterFirewallResponse raw = objectMapper.readValue( + result.stdout(), + RawRouterFirewallResponse.class + ); + + return new RouterFirewallRulesResponse( + raw.ok(), + raw.routerHost(), + parseFirewallRules(raw.firewall()), + java.time.Instant.now().toString() + ); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Invalid router firewall rules JSON returned by script", + e + ); + } + } + + private List parseFirewallRules(String firewall) { + if (firewall == null || firewall.isBlank()) { + return List.of(); + } + + Map> sections = new LinkedHashMap<>(); + + for (String line : firewall.split("\n")) { + line = line.trim(); + + if (line.isBlank()) continue; + + int equalsIndex = line.indexOf('='); + + if (equalsIndex < 0) continue; + + String left = line.substring(0, equalsIndex); + String value = line.substring(equalsIndex + 1) + .replace("'", ""); + + int dotIndex = left.indexOf('.'); + + if (dotIndex < 0) continue; + + String remainder = left.substring(dotIndex + 1); + + int fieldIndex = remainder.indexOf('.'); + + String sectionId; + String field; + + if (fieldIndex < 0) { + sectionId = remainder; + field = "__type"; + } else { + sectionId = remainder.substring(0, fieldIndex); + field = remainder.substring(fieldIndex + 1); + } + + sections + .computeIfAbsent(sectionId, ignored -> new LinkedHashMap<>()) + .put(field, value); + } + + return sections.entrySet().stream() + .map(entry -> { + String id = entry.getKey(); + Map fields = entry.getValue(); + + if (!"redirect".equals(fields.get("__type"))) { + return null; + } + + if (!"DNAT".equals(fields.get("target"))) { + return null; + } + + return new RouterDnatRule( + id, + fields.get("name"), + fields.get("proto"), + fields.get("src"), + fields.get("dest"), + fields.get("src_dport"), + fields.get("dest_ip"), + fields.get("dest_port"), + fields.get("target") + ); + }) + .filter(Objects::nonNull) + .toList(); + } + + public RouterFirewallRuleWriteResponse createRouterFirewallRule( + RouterFirewallRuleWriteRequest request + ) { + return runRouterFirewallWriteScript( + "/usr/local/sbin/lr-router-firewall-create", + request + ); + } + + public RouterFirewallRuleWriteResponse updateRouterFirewallRule( + RouterFirewallRuleWriteRequest request + ) { + return runRouterFirewallWriteScript( + "/usr/local/sbin/lr-router-firewall-update", + request + ); + } + + public RouterFirewallRuleWriteResponse deleteRouterFirewallRule( + RouterFirewallRuleDeleteRequest request + ) { + String command = "sudo /usr/local/sbin/lr-router-firewall-delete " + + shellQuote(request.routerHost()) + " " + + shellQuote(request.routerPassword()) + " " + + shellQuote(request.id()); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to delete router firewall rule: " + + result.stderr() + + result.stdout() + ); + } + + return new RouterFirewallRuleWriteResponse( + true, + request.routerHost(), + null, + request.id(), + java.time.Instant.now().toString() + ); + } + + private RouterFirewallRuleWriteResponse runRouterFirewallWriteScript( + String scriptPath, + RouterFirewallRuleWriteRequest request + ) { + String command = "sudo " + scriptPath + " " + + shellQuote(request.routerHost()) + " " + + shellQuote(request.routerPassword()) + " " + + shellQuote(request.id()) + " " + + shellQuote(request.name()) + " " + + shellQuote(request.proto()) + " " + + shellQuote(request.srcZone()) + " " + + shellQuote(request.destZone()) + " " + + shellQuote(request.externalPort()) + " " + + shellQuote(request.internalIp()) + " " + + shellQuote(request.internalPort()); + + SshCommandResult result = sshService.executeOnConfiguredVps(command); + + if (result.exitCode() != 0) { + throw new SshCommandException( + "Failed to write router firewall rule: " + + result.stderr() + + result.stdout() + ); + } + + return new RouterFirewallRuleWriteResponse( + true, + request.routerHost(), + new RouterDnatRule( + request.id(), + request.name(), + request.proto(), + request.srcZone(), + request.destZone(), + request.externalPort(), + request.internalIp(), + request.internalPort(), + "DNAT" + ), + null, + java.time.Instant.now().toString() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientCreateRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientCreateRequest.java new file mode 100644 index 0000000..af484b4 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientCreateRequest.java @@ -0,0 +1,6 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record ControllerClientCreateRequest( + String name, + String password +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileResponse.java new file mode 100644 index 0000000..314a587 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileResponse.java @@ -0,0 +1,5 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record ControllerClientFileResponse( + String content +) {} diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileUpdateRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileUpdateRequest.java new file mode 100644 index 0000000..e3f2e0d --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientFileUpdateRequest.java @@ -0,0 +1,5 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record ControllerClientFileUpdateRequest( + String content +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientSummary.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientSummary.java new file mode 100644 index 0000000..3463bb1 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientSummary.java @@ -0,0 +1,10 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record ControllerClientSummary( + String id, + String name, + String file, + String path, + Integer controller_count, + String modified_at +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientsResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientsResponse.java new file mode 100644 index 0000000..c0cbe0d --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/ControllerClientsResponse.java @@ -0,0 +1,7 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +import java.util.List; + +public record ControllerClientsResponse( + List clients +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/DeleteControllerClientRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/DeleteControllerClientRequest.java new file mode 100644 index 0000000..1e92f4e --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/DeleteControllerClientRequest.java @@ -0,0 +1,5 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record DeleteControllerClientRequest( + String clientId +) {} diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RawRouterFirewallResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RawRouterFirewallResponse.java new file mode 100644 index 0000000..3fa2ca5 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RawRouterFirewallResponse.java @@ -0,0 +1,7 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RawRouterFirewallResponse( + boolean ok, + String routerHost, + String firewall +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterDnatRule.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterDnatRule.java new file mode 100644 index 0000000..362ffb9 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterDnatRule.java @@ -0,0 +1,13 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RouterDnatRule( + String id, + String name, + String proto, + String srcZone, + String destZone, + String externalPort, + String internalIp, + String internalPort, + String target +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleDeleteRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleDeleteRequest.java new file mode 100644 index 0000000..b8ea57d --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleDeleteRequest.java @@ -0,0 +1,7 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RouterFirewallRuleDeleteRequest( + String routerHost, + String routerPassword, + String id +) {} diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteRequest.java new file mode 100644 index 0000000..8544b22 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteRequest.java @@ -0,0 +1,14 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RouterFirewallRuleWriteRequest( + String routerHost, + String routerPassword, + String id, + String name, + String proto, + String srcZone, + String destZone, + String externalPort, + String internalIp, + String internalPort +) {} diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteResponse.java new file mode 100644 index 0000000..6423abb --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRuleWriteResponse.java @@ -0,0 +1,9 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RouterFirewallRuleWriteResponse( + boolean ok, + String routerHost, + RouterDnatRule rule, + String deletedId, + String fetchedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesRequest.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesRequest.java new file mode 100644 index 0000000..89793cf --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesRequest.java @@ -0,0 +1,6 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +public record RouterFirewallRulesRequest( + String routerHost, + String routerPassword +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesResponse.java b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesResponse.java new file mode 100644 index 0000000..a4099b6 --- /dev/null +++ b/src/main/java/com/litoralregas/vpnorchestrator/vps/dto/RouterFirewallRulesResponse.java @@ -0,0 +1,10 @@ +package com.litoralregas.vpnorchestrator.vps.dto; + +import java.util.List; + +public record RouterFirewallRulesResponse( + boolean ok, + String routerHost, + List rules, + String fetchedAt +) {} \ No newline at end of file