fix(rotate-token): 加确认步骤、velocity 热加载免踢人,补齐 registry/forwarding/db 轮换
This commit is contained in:
16 files changed
+1568
-195
No files matched your search
+6
-2
@@ -74,7 +74,8 @@ carry works, but nothing installs them for you.
|
||||
> the `limbo` token: it opens the link-code, link-status and blacklist routes and nothing
|
||||
> else. The `velocity` token also approves op-logins and wakes or claims servers for any
|
||||
> player; it stays on the proxy host. `sudo felis rotate-token limbo` replaces a leaked
|
||||
> token (the login gate restarts onto the new value; copy it to the mod by hand).
|
||||
> token (the login gate restarts onto the new value; copy it into the mod's
|
||||
> `felis-link.properties` by hand; the mod takes it on its next call, without a restart).
|
||||
|
||||
## Whose identity each path trusts
|
||||
|
||||
@@ -379,7 +380,10 @@ login gate the `limbo` token (`felis-limbo-token`), and each serves only its own
|
||||
routes (a token on another caller's route gets `403 wrong_caller`). A loader mod
|
||||
takes the `limbo` token, on a standalone online-mode server only (see the warning
|
||||
under the module table). Treat it as a secret; `sudo felis rotate-token <caller>`
|
||||
replaces it.
|
||||
replaces it. A token read from this file follows the file: the proxy or mod
|
||||
presents a new `service-token` from its next call to felis-api (it re-reads the
|
||||
file at most once a second), logs `service-token reloaded from … (fingerprint …)`, and keeps its
|
||||
players. A token from `FELIS_SERVICE_TOKEN` is fixed until the process restarts.
|
||||
|
||||
On **Velocity**, also set `root-domain` (and optionally `lobby-server`) in the
|
||||
same file to turn on §11 routing, and make sure `online-mode=true` in
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* FileToken is a service token read from felis-link.properties that follows the
|
||||
* file while the process runs. {@code felis rotate-token velocity} rewrites the
|
||||
* file (a rename, so it is never seen half-written) and waits for the notice this
|
||||
* gives; the proxy presents the new token from its next felis-api call and keeps
|
||||
* every player connected, where a restart would have dropped them all.
|
||||
*
|
||||
* <p>The file is read again at most once every {@link #RECHECK_NANOS} ns, on the
|
||||
* calls themselves. A file that cannot be read, or holds no token, leaves the
|
||||
* current token in place: an operator's half-finished edit never blanks a token
|
||||
* that works.
|
||||
*/
|
||||
final class FileToken implements Supplier<String> {
|
||||
static final long RECHECK_NANOS = 1_000_000_000L;
|
||||
|
||||
private final Path file;
|
||||
private final String key;
|
||||
private final LongSupplier clock;
|
||||
private final Consumer<String> notice;
|
||||
private String current;
|
||||
private long checkedAt;
|
||||
|
||||
FileToken(Path file, String key, String initial, LongSupplier clock, Consumer<String> notice) {
|
||||
this.file = file;
|
||||
this.key = key;
|
||||
this.current = initial;
|
||||
this.clock = clock;
|
||||
this.notice = notice;
|
||||
this.checkedAt = clock.getAsLong();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String get() {
|
||||
long now = clock.getAsLong();
|
||||
if (now - checkedAt < RECHECK_NANOS) {
|
||||
return current;
|
||||
}
|
||||
checkedAt = now;
|
||||
String read = read();
|
||||
if (read != null && !read.equals(current)) {
|
||||
current = read;
|
||||
notice.accept("service-token reloaded from " + file + " (fingerprint " + fingerprint(read) + ")");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private String read() {
|
||||
Properties props = new Properties();
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
props.load(in);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
String v = props.getProperty(key);
|
||||
return v == null || v.trim().isEmpty() ? null : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* fingerprint names a token in a log line without giving it away: the first 12
|
||||
* hex digits of its SHA-256, which {@code felis rotate-token} computes the same
|
||||
* way to recognise the reload it is waiting for.
|
||||
*/
|
||||
static String fingerprint(String token) {
|
||||
try {
|
||||
byte[] sum = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(sum).substring(0, 12);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is missing from this JVM", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package best.lolicon.felis.link;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* LinkConfig is the immutable configuration a {@link LinkClient} needs to reach
|
||||
* the felis-api internal face. Both the base URL and the service token are
|
||||
* deployment inputs — operator config or a Secret-injected environment variable —
|
||||
* and are <em>never</em> compiled in. Keeping them out of source is what lets the
|
||||
* tree stay domain- and credential-free; each platform's config loader is
|
||||
* responsible for sourcing them.
|
||||
* LinkConfig is the configuration a {@link LinkClient} needs to reach the
|
||||
* felis-api internal face. It is fixed once built, except for a service token
|
||||
* read from felis-link.properties, which follows that file ({@link FileToken}).
|
||||
* Both the base URL and the service token are deployment inputs — operator config
|
||||
* or a Secret-injected environment variable — and are <em>never</em> compiled in.
|
||||
* Keeping them out of source is what lets the tree stay domain- and
|
||||
* credential-free; each platform's config loader is responsible for sourcing them.
|
||||
*
|
||||
* <p>Two timeouts bound each call: {@code connectTimeout} for opening the TCP
|
||||
* connection and {@code requestTimeout} for the whole exchange once it is open. A
|
||||
@@ -18,11 +20,17 @@ import java.util.Objects;
|
||||
*/
|
||||
public final class LinkConfig {
|
||||
private final String apiBaseUrl;
|
||||
private final String serviceToken;
|
||||
private final Supplier<String> serviceToken;
|
||||
private final Duration connectTimeout;
|
||||
private final Duration requestTimeout;
|
||||
|
||||
public LinkConfig(String apiBaseUrl, String serviceToken, Duration connectTimeout, Duration requestTimeout) {
|
||||
this(apiBaseUrl, fixed(serviceToken), connectTimeout, requestTimeout);
|
||||
}
|
||||
|
||||
// LinkConfig with a token that may change while the process runs (FileToken);
|
||||
// it must hold one from the start.
|
||||
LinkConfig(String apiBaseUrl, Supplier<String> serviceToken, Duration connectTimeout, Duration requestTimeout) {
|
||||
this.apiBaseUrl = stripTrailingSlash(Objects.requireNonNull(apiBaseUrl, "apiBaseUrl"));
|
||||
this.serviceToken = Objects.requireNonNull(serviceToken, "serviceToken");
|
||||
this.connectTimeout = Objects.requireNonNull(connectTimeout, "connectTimeout");
|
||||
@@ -30,7 +38,7 @@ public final class LinkConfig {
|
||||
if (this.apiBaseUrl.isEmpty()) {
|
||||
throw new IllegalArgumentException("apiBaseUrl is empty");
|
||||
}
|
||||
if (this.serviceToken.isEmpty()) {
|
||||
if (this.serviceToken.get().isEmpty()) {
|
||||
throw new IllegalArgumentException("serviceToken is empty");
|
||||
}
|
||||
if (this.connectTimeout.isZero() || this.connectTimeout.isNegative()) {
|
||||
@@ -49,8 +57,9 @@ public final class LinkConfig {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
/** serviceToken is the token to present on this call; FileToken may have replaced it since the last. */
|
||||
public String serviceToken() {
|
||||
return serviceToken;
|
||||
return serviceToken.get();
|
||||
}
|
||||
|
||||
public Duration connectTimeout() {
|
||||
@@ -61,6 +70,11 @@ public final class LinkConfig {
|
||||
return requestTimeout;
|
||||
}
|
||||
|
||||
private static Supplier<String> fixed(String token) {
|
||||
Objects.requireNonNull(token, "serviceToken");
|
||||
return () -> token;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String u) {
|
||||
String t = u.trim();
|
||||
while (t.endsWith("/")) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,9 @@ import java.util.function.UnaryOperator;
|
||||
* compiled in — this loader is the single seam each loader's entrypoint calls, so
|
||||
* the source tree stays domain- and credential-free. On first run it writes a
|
||||
* commented template and then reports the values as missing, so an operator gets
|
||||
* a file to fill in rather than a silent half-configured plugin.
|
||||
* a file to fill in rather than a silent half-configured plugin. A token from the
|
||||
* file follows the file while the process runs ({@link FileToken}), so
|
||||
* {@code felis rotate-token velocity} replaces it without restarting the proxy.
|
||||
*
|
||||
* <p>The two call timeouts ({@code connect-timeout-seconds},
|
||||
* {@code request-timeout-seconds}, or {@code FELIS_API_CONNECT_TIMEOUT_SECONDS} /
|
||||
@@ -51,11 +55,26 @@ public final class LinkConfigLoader {
|
||||
* timeout is not a whole number of seconds in range.
|
||||
*/
|
||||
public static LinkConfig load(Path propertiesFile) throws IOException {
|
||||
return load(propertiesFile, System::getenv);
|
||||
return load(propertiesFile, msg -> { });
|
||||
}
|
||||
|
||||
/**
|
||||
* load, with {@code notice} told when a token from the file is replaced while
|
||||
* the process runs ({@link FileToken}). A token from the environment is fixed
|
||||
* for the life of the process.
|
||||
*/
|
||||
public static LinkConfig load(Path propertiesFile, Consumer<String> notice) throws IOException {
|
||||
return load(propertiesFile, System::getenv, notice, System::nanoTime);
|
||||
}
|
||||
|
||||
// load with the environment passed in, so the precedence rules are testable.
|
||||
static LinkConfig load(Path propertiesFile, UnaryOperator<String> env) throws IOException {
|
||||
return load(propertiesFile, env, msg -> { }, System::nanoTime);
|
||||
}
|
||||
|
||||
// load with the environment and the clock passed in, so the precedence rules
|
||||
// and the file token's re-reads are testable.
|
||||
static LinkConfig load(Path propertiesFile, UnaryOperator<String> env, Consumer<String> notice,
|
||||
LongSupplier clock) throws IOException {
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(propertiesFile)) {
|
||||
try (InputStream in = Files.newInputStream(propertiesFile)) {
|
||||
@@ -76,6 +95,9 @@ public final class LinkConfigLoader {
|
||||
firstNonBlank(env.apply(ENV_CONNECT_TIMEOUT), props.getProperty(KEY_CONNECT_TIMEOUT)));
|
||||
Duration request = seconds(KEY_REQUEST_TIMEOUT, ENV_REQUEST_TIMEOUT,
|
||||
firstNonBlank(env.apply(ENV_REQUEST_TIMEOUT), props.getProperty(KEY_REQUEST_TIMEOUT)));
|
||||
if (isBlank(env.apply(ENV_TOKEN))) {
|
||||
return new LinkConfig(url, new FileToken(propertiesFile, KEY_TOKEN, token, clock, notice), connect, request);
|
||||
}
|
||||
return new LinkConfig(url, token, connect, request);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -15,7 +17,9 @@ import java.util.stream.Stream;
|
||||
* the environment wins, both required values must come from somewhere, a first run
|
||||
* leaves a template and still refuses to start, and the call timeouts default to
|
||||
* 10 s, take the operator's value, and refuse anything that is not a whole number
|
||||
* of seconds from 1 to 120.
|
||||
* of seconds from 1 to 120. A token from the file follows the file (at most one
|
||||
* look a second, a notice naming its fingerprint on each change, a blank or missing
|
||||
* file ignored); one from the environment stays fixed.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
|
||||
* shared/test/best/lolicon/felis/link/LinkConfigLoaderTest.java && java -cp <out>
|
||||
@@ -35,6 +39,8 @@ public final class LinkConfigLoaderTest {
|
||||
firstRunWritesATemplateAndRefuses();
|
||||
timeoutsComeFromFileOrEnvironment();
|
||||
badTimeoutsAreRefused();
|
||||
fileTokenFollowsTheFile();
|
||||
environmentTokenIsFixed();
|
||||
} finally {
|
||||
try (Stream<Path> walk = Files.walk(dir)) {
|
||||
walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
|
||||
@@ -124,6 +130,55 @@ public final class LinkConfigLoaderTest {
|
||||
assertContains("env bad names the variable", e.getMessage(), "FELIS_API_REQUEST_TIMEOUT_SECONDS");
|
||||
}
|
||||
|
||||
// `felis rotate-token velocity` rewrites the file and waits for the notice
|
||||
// naming the new token's fingerprint; the proxy presents the new token from
|
||||
// then on, without a restart.
|
||||
private static void fileTokenFollowsTheFile() throws IOException {
|
||||
Path f = write("rotate.properties", "api-base-url=http://x:8081\nservice-token=old-token\n");
|
||||
long[] now = {5_000_000_000L};
|
||||
List<String> notices = new ArrayList<>();
|
||||
LinkConfig c = LinkConfigLoader.load(f, env(), notices::add, () -> now[0]);
|
||||
assertEq("initial file token", "old-token", c.serviceToken());
|
||||
|
||||
write("rotate.properties", "api-base-url=http://x:8081\nservice-token=new-token\n");
|
||||
now[0] += FileToken.RECHECK_NANOS - 1;
|
||||
assertEq("within a second of the last look the file is not read", "old-token", c.serviceToken());
|
||||
now[0] += 1;
|
||||
assertEq("a second on, the rewritten token is presented", "new-token", c.serviceToken());
|
||||
assertEq("one notice for one change", 1, notices.size());
|
||||
write("rotate.properties", "api-base-url=http://x:8081\nservice-token=third-token\n");
|
||||
now[0] += FileToken.RECHECK_NANOS - 1;
|
||||
assertEq("the second counts from the last look", "new-token", c.serviceToken());
|
||||
write("rotate.properties", "api-base-url=http://x:8081\nservice-token=new-token\n");
|
||||
assertEq("the notice names the file and the fingerprint",
|
||||
"service-token reloaded from " + f + " (fingerprint 348e9df2a42b)", notices.get(0));
|
||||
now[0] += FileToken.RECHECK_NANOS;
|
||||
c.serviceToken();
|
||||
assertEq("an unchanged file gives no notice", 1, notices.size());
|
||||
|
||||
// A file mid-edit, or gone, keeps the token that works.
|
||||
write("rotate.properties", "api-base-url=http://x:8081\nservice-token= \n");
|
||||
now[0] += FileToken.RECHECK_NANOS;
|
||||
assertEq("a blank token in the file is ignored", "new-token", c.serviceToken());
|
||||
Files.delete(f);
|
||||
now[0] += FileToken.RECHECK_NANOS;
|
||||
assertEq("a missing file is ignored", "new-token", c.serviceToken());
|
||||
assertEq("neither gave a notice", 1, notices.size());
|
||||
}
|
||||
|
||||
// The login gate's token comes from its pod's environment, which only a
|
||||
// restart changes: the file does not override it later.
|
||||
private static void environmentTokenIsFixed() throws IOException {
|
||||
Path f = write("env-fixed.properties", "api-base-url=http://x:8081\nservice-token=file-token\n");
|
||||
long[] now = {0};
|
||||
List<String> notices = new ArrayList<>();
|
||||
LinkConfig c = LinkConfigLoader.load(f, env("FELIS_SERVICE_TOKEN", "env-token"), notices::add, () -> now[0]);
|
||||
write("env-fixed.properties", "api-base-url=http://x:8081\nservice-token=other-token\n");
|
||||
now[0] += 10 * FileToken.RECHECK_NANOS;
|
||||
assertEq("env token stays", "env-token", c.serviceToken());
|
||||
assertEq("env token gives no notice", 0, notices.size());
|
||||
}
|
||||
|
||||
// ---- harness ----
|
||||
|
||||
private static java.util.function.UnaryOperator<String> env(String... kv) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* FelisVelocityConfig extends the shared link config with the inputs only the full
|
||||
@@ -62,8 +63,9 @@ final class FelisVelocityConfig {
|
||||
this.adminHostname = adminHostname;
|
||||
}
|
||||
|
||||
static FelisVelocityConfig load(Path file) throws IOException {
|
||||
LinkConfig link = LinkConfigLoader.load(file); // url + token (required) + template + validate
|
||||
// load reads the file; notice hears of a service token replaced in it later.
|
||||
static FelisVelocityConfig load(Path file, Consumer<String> notice) throws IOException {
|
||||
LinkConfig link = LinkConfigLoader.load(file, notice); // url + token (required) + template + validate
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(file)) {
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
|
||||
@@ -131,7 +131,8 @@ public final class FelisVelocityPlugin {
|
||||
@Subscribe
|
||||
public void onProxyInitialize(ProxyInitializeEvent event) {
|
||||
try {
|
||||
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"));
|
||||
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"),
|
||||
msg -> logger.info("Felis: {}", msg));
|
||||
} catch (Exception e) {
|
||||
logger.error("Felis disabled: {}", e.getMessage());
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user