Compare commits

..

6 Commits

Author SHA1 Message Date
Stefan Meinecke
c1c827d2d3
Merge 67105e14c19d9abf09a3ab133bd43da13662a7d6 into dc43e4402050fc774ef2f420e1f7578da0290e17 2026-04-29 22:53:07 +02:00
Stefan Meinecke
67105e14c1
Merge branch 'master' into fix/unauthenticated-socket-keepalive 2026-04-23 10:23:51 +00:00
Stefan Meinecke
f27eb524de Merge branch 'master' of https://github.com/AsamK/signal-cli into fix/unauthenticated-socket-keepalive 2026-04-17 11:49:13 +02:00
Stefan Meinecke
0006fd0dc0 Tie unauthenticated WebSocket keep-alive to active client connections
Keep the unidentified socket alive while a JSON-RPC connection is open (including stdio mode) and while a D-Bus object is exported, instead of for the lifetime of the receive loop. The receive loop does not use the unauthenticated socket, so keeping it alive there was semantically wrong.

This also covers --receive-mode=manual, where no receive loop runs butclients still send messages.
2026-04-17 11:49:05 +02:00
Stefan Meinecke
72332750a8 Merge branch 'master' of https://github.com/AsamK/signal-cli into fix/unauthenticated-socket-keepalive 2026-04-15 11:42:43 +02:00
Stefan Meinecke
8310b16895 Keep unauthenticated WebSocket alive during daemon receive loop
The unauthenticated (sealed sender) socket had no keep-alive token
registered, causing SignalWebSocket's DelayedDisconnectThread to tear
down the connection ~10s after each send. Every subsequent group message
then had to re-establish a fresh TLS connection (~6s delay).

The authenticated socket avoids this by registering a "receive" keep-alive
token for the lifetime of the receive loop. Apply the same pattern to the
unauthenticated socket: register the token alongside the authenticated one
and remove it in the same finally block.

This keeps the unidentified connection alive in daemon mode, matching the
behaviour of Signal mobile clients.
2026-04-14 19:59:42 +02:00
18 changed files with 93 additions and 110 deletions

View File

@ -15,7 +15,7 @@ jobs:
matrix:
# java="25" is the LTS Java version used in reproducible builds script (default in Containerfile).
# More Java versions can be added to test compatibility, eg. "26".
java: ["25", "26"]
java: ["25"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

View File

@ -1,9 +1,9 @@
[versions]
slf4j = "2.0.18"
slf4j = "2.0.17"
junit = "6.0.3"
micronaut-json-schema = "2.0.0"
micronaut-core = "5.0.0"
signal-service = "2.15.3_unofficial_146"
micronaut-json-schema = "2.0.0-M8"
micronaut-core = "4.9.3"
signal-service = "2.15.3_unofficial_145"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
@ -20,7 +20,7 @@ slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.32"
signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.53.1.0"
sqlite = "org.xerial:sqlite-jdbc:3.53.0.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

Binary file not shown.

View File

@ -1,9 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

31
gradlew.bat vendored
View File

@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@ -65,7 +65,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
goto fail
:execute
@rem Setup the command line
@ -73,10 +73,21 @@ echo location of your Java installation. 1>&2
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -259,7 +259,7 @@ public interface Manager extends Closeable {
RecipientIdentifier.Single recipient
) throws IOException;
void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type type,

View File

@ -157,7 +157,7 @@ public record MessageEnvelope(
dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(),
false,
dataMessage.isEndSession(),
dataMessage.isProfileKeyUpdate(),
dataMessage.getProfileKey().isPresent(),
dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)),
@ -1028,7 +1028,7 @@ public record MessageEnvelope(
final AttachmentFileProvider fileProvider,
Exception exception
) {
final var serviceId = envelope.getSourceServiceId();
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
final var source = !envelope.isUnidentifiedSender() && serviceId != null
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null

View File

@ -109,8 +109,8 @@ public final class IncomingMessageHandler {
SignalServiceContent content = null;
if (!envelope.isReceipt()) {
account.getIdentityKeyStore().setRetryingDecryption(true);
final var destination = getDestination(envelope).serviceId();
try {
final var destination = getDestination(envelope).serviceId();
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -140,30 +140,15 @@ public final class IncomingMessageHandler {
final Manager.ReceiveMessageHandler handler
) {
final var actions = new ArrayList<HandleAction>();
if (envelope.isPreKeySignalMessage()) {
actions.add(RefreshPreKeysAction.create());
}
SignalServiceContent content = null;
Exception exception = null;
if (envelope.getSourceServiceId() != null) {
// Store uuid if we don't have it already
// uuid in envelope is sent by server
account.getRecipientResolver().resolveRecipient(envelope.getSourceServiceId());
}
envelope.getSourceServiceId().map(ServiceId::parseOrNull)
// Store uuid if we don't have it already
// uuid in envelope is sent by server
.ifPresent(serviceId -> account.getRecipientResolver().resolveRecipient(serviceId));
if (!envelope.isReceipt()) {
final var destination = getDestination(envelope).serviceId();
try {
final var destination = getDestination(envelope).serviceId();
if (destination == account.getPni() && envelope.getSourceServiceId() == null) {
throw new InvalidMessageException(
"Got a sealed sender message to our PNI? Invalid message, ignoring.");
}
if (envelope.getSourceServiceId() instanceof ServiceId.PNI
&& envelope.getProto().type != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
throw new InvalidMessageException("Got a message from a PNI that was not a SERVER_DELIVERY_RECEIPT.");
}
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -188,13 +173,7 @@ public final class IncomingMessageHandler {
logger.debug("Received invalid message from blocked contact, ignoring.");
} else {
var serviceId = ServiceId.parseOrNull(e.getSender());
ServiceId destination;
try {
destination = getDestination(envelope).serviceId();
} catch (InvalidMessageException ex) {
destination = null;
}
if (serviceId != null && destination != null) {
if (serviceId != null) {
final var isSelf = sender.equals(account.getSelfRecipientId())
&& e.getSenderDevice() == account.getDeviceId();
logger.debug("Received invalid message, queuing renew session action.");
@ -332,12 +311,7 @@ public final class IncomingMessageHandler {
final var sender = senderDeviceAddress.recipientId();
final var senderServiceId = senderDeviceAddress.serviceId();
final var senderDeviceId = senderDeviceAddress.deviceId();
final DeviceAddress destination;
try {
destination = getDestination(envelope);
} catch (InvalidMessageException e) {
throw new AssertionError(e);
}
final var destination = getDestination(envelope);
if (account.getPni().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true);
@ -900,6 +874,11 @@ public final class IncomingMessageHandler {
final var selfAddress = isSync ? source : destination;
final var conversationPartnerAddress = isSync ? destination : source;
if (conversationPartnerAddress != null && message.isEndSession()) {
account.getAccountData(selfAddress.serviceId())
.getSessionStore()
.deleteAllSessions(conversationPartnerAddress.serviceId());
}
if (message.isExpirationUpdate() || message.getBody().isPresent()) {
if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get();
@ -1068,7 +1047,7 @@ public final class IncomingMessageHandler {
}
private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId();
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new SignalServiceAddress(serviceId);
} else if (content != null) {
@ -1079,7 +1058,7 @@ public final class IncomingMessageHandler {
}
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId();
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null);
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(serviceId),
serviceId,
@ -1091,13 +1070,10 @@ public final class IncomingMessageHandler {
}
}
private DeviceAddress getDestination(SignalServiceEnvelope envelope) throws InvalidMessageException {
private DeviceAddress getDestination(SignalServiceEnvelope envelope) {
final var destination = envelope.getDestinationServiceId();
if (destination == null || destination.isUnknown()) {
throw new InvalidMessageException("Missing destination");
}
if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
throw new InvalidMessageException("Message not intended for this account");
return new DeviceAddress(account.getSelfRecipientId(), account.getAci(), account.getDeviceId());
}
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination,

View File

@ -9,6 +9,7 @@ import org.asamk.signal.manager.jobs.CleanOldPreKeysJob;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.messageCache.CachedMessage;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -149,10 +150,10 @@ public class ReceiveHelper {
for (final var it : batch) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(),
it.getServerDeliveredTimestamp());
final var sourceServiceId = envelope1.getSourceServiceId();
final var recipientId = sourceServiceId == null
? null
: account.getRecipientResolver().resolveRecipient(sourceServiceId);
final var recipientId = envelope1.getSourceServiceId()
.map(ServiceId::parseOrNull)
.map(s -> account.getRecipientResolver().resolveRecipient(s))
.orElse(null);
logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
@ -237,7 +238,7 @@ public class ReceiveHelper {
if (exception instanceof UntrustedIdentityException) {
logger.debug("Keeping message with untrusted identity in message cache");
final var address = ((UntrustedIdentityException) exception).getSender();
if (envelope.getSourceServiceId() == null && address.aci().isPresent()) {
if (envelope.getSourceServiceId().isEmpty() && address.aci().isPresent()) {
final var recipientId = account.getRecipientResolver()
.resolveRecipient(ACI.parseOrThrow(address.aci().get()));
try {
@ -291,7 +292,7 @@ public class ReceiveHelper {
cachedMessage.delete();
return null;
}
if (envelope.getSourceServiceId() == null) {
if (envelope.getSourceServiceId().isEmpty()) {
final var identifier = ((UntrustedIdentityException) exception).getSender();
final var recipientId = account.getRecipientResolver()
.resolveRecipient(new RecipientAddress(identifier));

View File

@ -1091,26 +1091,30 @@ public class ManagerImpl implements Manager {
}
@Override
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException {
for (var recipient : recipients) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
continue;
}
final var recipientAddress = context.getAccount()
.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId);
final var aciSessionStore = account.getAccountData(ServiceIdType.ACI).getSessionStore();
final var pniSessionStore = account.getAccountData(ServiceIdType.PNI).getSessionStore();
if (recipientAddress.aci().isPresent()) {
aciSessionStore.archiveSessions(recipientAddress.aci().get());
pniSessionStore.archiveSessions(recipientAddress.aci().get());
}
if (recipientAddress.pni().isPresent()) {
aciSessionStore.archiveSessions(recipientAddress.pni().get());
pniSessionStore.archiveSessions(recipientAddress.pni().get());
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException {
var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage();
try {
return sendMessage(messageBuilder,
recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()),
false);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new AssertionError(e);
} finally {
for (var recipient : recipients) {
final RecipientId recipientId;
try {
recipientId = context.getRecipientHelper().resolveRecipient(recipient);
} catch (UnregisteredRecipientException e) {
continue;
}
final var serviceId = context.getAccount()
.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.serviceId();
if (serviceId.isPresent()) {
account.getAccountData(ServiceIdType.ACI).getSessionStore().deleteAllSessions(serviceId.get());
}
}
}
}

View File

@ -368,8 +368,7 @@ public class SignalDependencies {
public SignalServiceCipher getCipher(ServiceIdType serviceIdType) {
final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.unidentifiedSenderTrustRoots());
final var serviceId = serviceIdType == ServiceIdType.ACI ? credentialsProvider.getAci() : credentialsProvider.getPni();
final var address = new SignalServiceAddress(serviceId, credentialsProvider.getE164());
final var address = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164());
final var deviceId = credentialsProvider.getDeviceId();
return new SignalServiceCipher(address,
deviceId,

View File

@ -6,7 +6,7 @@ ENV SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH
ENV LANG=C.UTF-8
ENV LC_CTYPE=en_US.UTF-8
RUN SNAPSHOT="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y%m%dT%H%M%SZ)" \
&& sed -i 's/^deb /deb [snapshot=yes] /' /etc/apt/sources.list && apt update --snapshot "$SNAPSHOT" && apt install -y make asciidoc-base --snapshot "$SNAPSHOT" --no-install-recommends --no-install-suggests
&& apt install -y make asciidoc-base --update --snapshot "$SNAPSHOT" --no-install-recommends --no-install-suggests
COPY --chmod=0700 reproducible-builds/entrypoint.sh /usr/local/bin/entrypoint.sh
WORKDIR /signal-cli
ENTRYPOINT [ "/usr/local/bin/entrypoint.sh", "build" ]

View File

@ -144,7 +144,8 @@ public class SendCommand implements JsonRpcLocalCommand {
}
try {
m.sendEndSessionMessage(singleRecipients);
final var results = m.sendEndSessionMessage(singleRecipients);
outputResult(outputWriter, results);
return;
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()

View File

@ -543,8 +543,9 @@ public class DbusManagerImpl implements Manager {
}
@Override
public void sendEndSessionMessage(final Set<RecipientIdentifier.Single> recipients) throws IOException {
public SendMessageResults sendEndSessionMessage(final Set<RecipientIdentifier.Single> recipients) throws IOException {
signal.sendEndSessionMessage(recipients.stream().map(RecipientIdentifier.Single::getIdentifier).toList());
return new SendMessageResults(0, Map.of());
}
@Override

View File

@ -432,7 +432,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
@Override
public void sendEndSessionMessage(final List<String> recipients) {
try {
m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getSelfNumber()));
final var results = m.sendEndSessionMessage(getSingleRecipientIdentifiers(recipients, m.getSelfNumber()));
checkSendMessageResults(results);
} catch (IOException e) {
throw new Error.Failure(e.getMessage());
}

View File

@ -33,7 +33,6 @@ import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class SignalJsonRpcDispatcherHandler {
@ -103,9 +102,7 @@ public class SignalJsonRpcDispatcherHandler {
private int subscribeCallEvents(final Collection<Manager> managers) {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var listeners = managers.stream()
.map(m -> createCallEventHandler(m, subscriptionId))
.collect(Collectors.toCollection(ArrayList::new));
final var listeners = managers.stream().map(m -> createCallEventHandler(m, subscriptionId)).toList();
callEventHandlers.put(subscriptionId, listeners);
return subscriptionId;
}
@ -160,7 +157,7 @@ public class SignalJsonRpcDispatcherHandler {
final var subscriptionId = nextSubscriptionId.getAndIncrement();
final var handlers = managers.stream()
.map(m -> createReceiveHandler(m, subscriptionId, internalSubscription))
.collect(Collectors.toCollection(ArrayList::new));
.toList();
receiveHandlers.put(subscriptionId, handlers);
return subscriptionId;

View File

@ -326,7 +326,8 @@ class SseInitialFlushTest {
}
@Override
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) {
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) {
return null;
}
@Override
@ -447,14 +448,6 @@ class SseInitialFlushTest {
public void addClosedListener(Runnable listener) {
}
@Override
public void addUnidentifiedKeepAlive(String token) {
}
@Override
public void removeUnidentifiedKeepAlive(String token) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;

View File

@ -328,7 +328,8 @@ class SubscribeCallEventsTest {
}
@Override
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> r) {
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> r) {
return null;
}
@Override