Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@
*/
package org.openremote.extension.ems.manager.gopacs;

import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;

import static jakarta.ws.rs.core.MediaType.APPLICATION_XML;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;

@Path("v2/participants/DSO")
@Path("uftp-participants/v3/participants")
public interface GOPACSAddressBookResource {
@GET
@Consumes(APPLICATION_XML)
Response fetchParticipants(@QueryParam("contractedEan") String contractedEan);
@Path("{uftpDomainName}")
@Produces(APPLICATION_JSON)
Response fetchParticipantByDomain(
@HeaderParam("Authorization") String authorization,
@PathParam("uftpDomainName") String uftpDomainName
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.core.GenericType;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.lfenergy.shapeshifter.api.*;
Expand Down Expand Up @@ -88,8 +87,10 @@ public class GOPACSHandler implements UftpPayloadHandler, UftpParticipantService

private static final Logger LOG = SyslogCategory.getLogger(API, GOPACSHandler.class);
public static final String GOPACS_PRIVATE_KEY_FILE = "GOPACS_PRIVATE_KEY_FILE";
public static final String GOPACS_BROKER_URL = "GOPACS_BROKER_URL";
public static final String DEFAULT_GOPACS_BROKER_URL = "https://clc-message-broker.gopacs-services.eu";
public static final String GOPACS_PARTICIPANT_URL = "GOPACS_PARTICIPANT_URL";
public static final String DEFAULT_GOPACS_PARTICIPANT_URL = "https://clc-message-broker.gopacs-services.eu";
public static final String DEFAULT_GOPACS_PARTICIPANT_URL = "https://api.gopacs-services.eu";
public static final String GOPACS_OAUTH2_URL = "GOPACS_OAUTH2_URL";
public static final String DEFAULT_GOPACS_OAUTH2_URL = "https://auth.gopacs-services.eu/realms/gopacs/protocol/openid-connect/token";
public static final String GOPACS_CLIENT_ID = "GOPACS_CLIENT_ID";
Expand All @@ -107,6 +108,7 @@ public class GOPACSHandler implements UftpPayloadHandler, UftpParticipantService
protected final String contractedEAN;
protected final String electricitySupplierAssetId;
protected final String realm;
protected final String gopacsBrokerUrl;
protected final Map<String, UftpParticipantInformation> participants;

protected final AssetProcessingService assetProcessingService;
Expand Down Expand Up @@ -160,6 +162,8 @@ protected GOPACSHandler(String contractedEAN, String realm, String electricitySu
this.timerService = container.getService(TimerService.class);
this.webService = container.getService(WebService.class);

// Strip trailing slashes so the synthesised broker endpoint never contains a double slash
this.gopacsBrokerUrl = container.getConfig().getOrDefault(GOPACS_BROKER_URL, DEFAULT_GOPACS_BROKER_URL).replaceAll("/+$", "");
this.responseDelaySeconds = Integer.parseInt(container.getConfig().getOrDefault(GOPACS_RESPONSE_DELAY_SECONDS, DEFAULT_GOPACS_RESPONSE_DELAY_SECONDS));
this.flexOfferDelaySeconds = Integer.parseInt(container.getConfig().getOrDefault(GOPACS_FLEX_OFFER_DELAY_SECONDS, DEFAULT_GOPACS_FLEX_OFFER_DELAY_SECONDS));

Expand Down Expand Up @@ -293,8 +297,15 @@ public void notifyNewOutgoingMessage(OutgoingUftpMessage<? extends PayloadMessag
@Override
public String getAuthorizationHeader(UftpParticipant uftpParticipant) {
LOG.fine("Getting authorization header for: " + uftpParticipant);
String authorization = fetchBearerToken();
if (authorization.isBlank()) {
LOG.warning("No OAuth2 bearer token available for authorization header of " + uftpParticipant);
}
return authorization;
}

protected String fetchBearerToken() {
try {
// Perform OAuth2 client credentials flow
try (Response response = gopacsAuthResource.getAccessToken(
"client_credentials",
this.clientId,
Expand All @@ -303,13 +314,10 @@ public String getAuthorizationHeader(UftpParticipant uftpParticipant) {
if (response.getStatus() == 200) {
String responseBody = response.readEntity(String.class);
OAuth2TokenResponse tokenResponse = objectMapper.readValue(responseBody, OAuth2TokenResponse.class);

// Return Bearer token header
return "Bearer " + tokenResponse.getAccessToken();
} else {
LOG.warning("OAuth2 token request failed with status: " + response.getStatus());
return "";
}
LOG.warning("OAuth2 token request failed with status: " + response.getStatus());
return "";
}
} catch (Exception e) {
LOG.log(Level.SEVERE, "Failed to obtain OAuth2 access token", e);
Expand Down Expand Up @@ -493,6 +501,20 @@ protected long calculatePower(FlexRequestISPType requestIsp) {
return requestIsp.getMaxPower() == 0 ? requestIsp.getMinPower() : requestIsp.getMaxPower();
}

/**
* Only act on flex messages whose congestion point matches this handler's contracted EAN. Returns
* false (and logs a warning) for out-of-scope messages so they are dropped before any asset mutation
* or outbound response. See issue #28 for full per-contract/role scoping via the V3 contracts endpoint.
*/
protected boolean isWithinContractedScope(String messageType, String conversationId, String congestionPoint) {
if (contractedEAN.equals(congestionPoint)) {
return true;
}
LOG.warning("Rejecting " + messageType + " " + conversationId + " for out-of-scope congestion point "
+ congestionPoint + " (contracted EAN " + contractedEAN + ")");
return false;
}

protected void processRawMessage(String transportXml) {
try {
SignedMessage signedMessage = serializer.fromSignedXml(transportXml);
Expand All @@ -501,6 +523,19 @@ protected void processRawMessage(String transportXml) {
LOG.fine("Received message:" + payloadXml);
}
PayloadMessageType payloadMessage = serializer.fromPayloadXml(payloadXml);

// Re-assert EAN scoping that the V2 participant lookup enforced implicitly. The V3 lookup
// resolves any participant domain, so a validly signed flex message from a participant outside
// this handler's contracted EAN would otherwise be applied to the asset. Full per-contract/role
// scoping via the V3 contracts endpoint is tracked in issue #28.
// Out-of-scope messages are intentionally dropped here: the transport call still returns 200
// (signed envelope accepted) but no FlexRequestResponse/FlexOffer is sent in reply.
if (payloadMessage instanceof FlexMessageType flexMessage
&& !isWithinContractedScope(payloadMessage.getClass().getSimpleName(),
payloadMessage.getConversationID(), flexMessage.getCongestionPoint())) {
return;
}

var incomingUftpMessage = IncomingUftpMessage.create(new UftpParticipant(signedMessage), payloadMessage, transportXml, payloadXml);
notifyNewIncomingMessage(incomingUftpMessage);

Expand Down Expand Up @@ -545,23 +580,29 @@ protected void processRawMessage(String transportXml) {
public Optional<UftpParticipantInformation> getParticipantInformation(USEFRoleType role, String domain) {
if (participants.containsKey(domain)) {
return Optional.of(participants.get(domain));
} else {
try (Response response = gopacsAddressBookResource.fetchParticipants(contractedEAN)) {
if (response != null && response.getStatus() == 200) {
List<UftpParticipantInformation> participants = response.readEntity(new GenericType<>() {
});
for (UftpParticipantInformation participant : participants) {
this.participants.put(participant.domain(), new UftpParticipantInformation(participant.domain(), participant.publicKey(), participant.endpoint(), true));
}
return participants.stream().filter(p -> p.domain().equals(domain)).findFirst();
}
} catch (Exception e) {
if (e.getCause() != null && e.getCause() instanceof IOException) {
LOG.log(Level.SEVERE, "Exception when requesting participant information", e.getCause());
} else {
LOG.log(Level.SEVERE, "Exception when requesting participant information", e);
}
}

String authorization = fetchBearerToken();
Comment thread
wborn marked this conversation as resolved.
if (authorization.isBlank()) {
LOG.warning("Skipping participant lookup for " + domain + ": no OAuth2 bearer token available");
return Optional.empty();
}
try (Response response = gopacsAddressBookResource.fetchParticipantByDomain(authorization, domain)) {
Comment thread
wborn marked this conversation as resolved.
int status = response != null ? response.getStatus() : -1;
if (status == 200) {
ParticipantView view = response.readEntity(ParticipantView.class);
UftpParticipantInformation info = new UftpParticipantInformation(view.domain(), view.publicKey(), this.gopacsBrokerUrl + "/shapeshifter/api/v3/message", true);
Comment thread
wborn marked this conversation as resolved.
participants.put(view.domain(), info);
return Optional.of(info);
}
if (status == 404) {
LOG.fine("Participant not found in GOPACS address book: " + domain);
} else {
LOG.severe("Unexpected status " + status + " when requesting participant information for " + domain);
}
} catch (Exception e) {
Throwable cause = e.getCause() instanceof IOException ? e.getCause() : e;
LOG.log(Level.SEVERE, "Exception when requesting participant information for " + domain, cause);
}
return Optional.empty();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright 2026, OpenRemote Inc.
*
* See the CONTRIBUTORS.txt file in the distribution for a
* full listing of individual contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.openremote.extension.ems.manager.gopacs;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public record ParticipantView(String domain, String publicKey) {}
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ openremoteVersion = 1.22.1
bouncyCastleVersion = 1.81
jacksonVersion = 2.21.2
resteasyVersion = 6.2.15.Final
shapeshifterVersion = 3.2.2
shapeshifterVersion = 3.5.0
testLoggerVersion = 4.0.0
Loading