fix(rotate-token): 加确认步骤、velocity 热加载免踢人,补齐 registry/forwarding/db 轮换

This commit is contained in:
Lemon-miaow committed 2026-09-27 10:50:11 +08:00
1 parent d112a43c65
commit 2d04e0e637
16 files changed
+1568 -195

No files matched your search

@@ -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) {