fix(rotate-token): 加确认步骤、velocity 热加载免踢人,补齐 registry/forwarding/db 轮换
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user