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
2 changes: 2 additions & 0 deletions clear-elasticsearch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE
unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-elasticsearch.sh / setup-opensearch.sh
unset UNOMI_DISTRIBUTION
unset UNOMI_ROOT_PASSWORD
unset UNOMI_HEALTHCHECK_PASSWORD

unset _IS_SOURCED

Expand Down
2 changes: 2 additions & 0 deletions clear-opensearch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE
unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-opensearch.sh / setup-elasticsearch.sh
unset UNOMI_DISTRIBUTION
unset UNOMI_ROOT_PASSWORD
unset UNOMI_HEALTHCHECK_PASSWORD

unset _IS_SOURCED

Expand Down
18 changes: 16 additions & 2 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ required Unomi tarball.

## Launching docker-compose using Maven project

Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose:
Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose.

Set admin and health passwords first (required; no known defaults are shipped):

```
export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
mvn docker:start
```

Expand Down Expand Up @@ -72,6 +76,8 @@ For Unomi (with ElasticSearch):
```bash
docker pull apache/unomi:3.1.0-SNAPSHOT
docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
-e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
-e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \
apache/unomi:3.1.0-SNAPSHOT
```
Expand All @@ -81,6 +87,8 @@ For Unomi (with OpenSearch):
```bash
docker pull apache/unomi:3.1.0-SNAPSHOT
docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
-e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
-e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
-e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \
-e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
Expand All @@ -93,6 +101,8 @@ For ElasticSearch:

```bash
docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
-e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
-e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \
apache/unomi:3.1.0-SNAPSHOT
```
Expand All @@ -101,6 +111,8 @@ For OpenSearch:

```bash
docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
-e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
-e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
-e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \
-e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
Expand All @@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s
## Environment Variables

### Common Variables
- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default
- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no known default
- `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`)
- `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`)

Expand All @@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints such as `/cxs/context.js

```bash
curl -X POST http://localhost:8181/cxs/tenants \
--user karaf:karaf \
--user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"requestedId":"default","properties":{"name":"Default Tenant"}}'
```
Expand Down
2 changes: 2 additions & 0 deletions docker/src/main/docker/docker-compose-build-es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ services:
- UNOMI_AUTO_START=true
- UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
Expand Down
2 changes: 2 additions & 0 deletions docker/src/main/docker/docker-compose-build-os.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ services:
- UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
- UNOMI_OPENSEARCH_USERNAME=admin
- UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
Expand Down
4 changes: 4 additions & 0 deletions docker/src/main/docker/docker-compose-cluster.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_CLUSTER_NODEID=unomi-3-node-1
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8181:8181
- 9443:9443
Expand All @@ -58,6 +60,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_CLUSTER_NODEID=unomi-3-node-2
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8182:8181
- 9444:9443
Expand Down
3 changes: 3 additions & 0 deletions docker/src/main/docker/docker-compose-es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ services:
- UNOMI_AUTO_START=true
- UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
# Required admin password (no known default is shipped). Override via .env / shell.
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
Expand Down
3 changes: 3 additions & 0 deletions docker/src/main/docker/docker-compose-os.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ services:
- UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
- UNOMI_OPENSEARCH_USERNAME=admin
- UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
# Required admin password (no known default is shipped). Override via .env / shell.
- UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
- UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
Expand Down
41 changes: 41 additions & 0 deletions docker/src/main/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${

echo "KARAF_OPTS: $KARAF_OPTS"

# Refuse to start without admin/health passwords. An unset password is not "no account": it
# expands to the empty string, which Karaf's PropertiesLoginModule accepts as a valid password.
# This is the gate for container launches: exiting here means the container fails to start rather
# than booting with an administrator account that accepts an empty password.
check_required_password() {
# $1 env var name, $2 property name, $3 skip flag name
eval _value=\"\${$1}\"
eval _skip=\"\${$3}\"

[ -n "${_value}" ] && return 0

if [ "${_skip}" = "true" ]; then
cat >&2 <<EOF

WARNING: $3=true but $1 is empty.
Unless $2 is supplied another way, the
account will have an EMPTY password that grants full administrator access.

EOF
return 0
fi

cat >&2 <<EOF
ERROR: $1 is not set.

Apache Unomi does not ship a known default password, and an unset value becomes an EMPTY
password that still authenticates. Pass it when starting the container, for example:

docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \\
-e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ...

Or with docker compose, export both UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD first.
EOF
return 1
}

check_required_password UNOMI_ROOT_PASSWORD \
org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1
check_required_password UNOMI_HEALTHCHECK_PASSWORD \
org.apache.unomi.healthcheck.password UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1

# Function to check cluster health for a specific node
check_node_health() {
local node_url="$1"
Expand Down
2 changes: 1 addition & 1 deletion extensions/healthcheck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The health check endpoint is available at
```
and returns a simple JSON response that includes all health check provider responses.

Basic Http Authentication is enabled by default for the health check endpoint. The user needs to have the role `health` to access the endpoint. Users and roles can be configured in the etc/users.properties file. By default a user health/health is configured.
Basic Http Authentication is enabled by default for the health check endpoint. The user needs to have the role `health` to access the endpoint. Users and roles can be configured in the etc/users.properties file. The shipped `health` user has no default password: its password comes from the `UNOMI_HEALTHCHECK_PASSWORD` environment variable, which must be set before starting. An empty password is never accepted.

The healthcheck is available even if unomi is not started. It gives health information about :
- Karaf (as soon as the karaf container is started)
Expand Down
10 changes: 10 additions & 0 deletions extensions/healthcheck/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@
<artifactId>org.apache.karaf.jaas.boot</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

/**
Expand All @@ -41,6 +42,8 @@ public class HealthCheckHttpContext implements HttpContext {

private static final Logger LOGGER = LoggerFactory.getLogger(HealthCheckHttpContext.class.getName());

private static final String BASIC_PREFIX = "Basic ";

private final String realm;

public HealthCheckHttpContext(String realm) {
Expand All @@ -67,20 +70,34 @@ public boolean handleSecurity(HttpServletRequest req, HttpServletResponse res) t
protected boolean authenticated(HttpServletRequest request) {
request.setAttribute(AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH);

String authzHeader = request.getHeader("Authorization");
String usernameAndPassword = new String(Base64.getDecoder().decode(authzHeader.substring(6).getBytes()));
String[] parts = usernameAndPassword.split(":");
String[] parts = extractBasicCredentials(request.getHeader("Authorization"));
if (parts == null) {
LOGGER.debug("Malformed Basic credentials, refusing access");
return false;
}
final String user = parts[0];
final String password = parts[1];

// An unset org.apache.unomi.healthcheck.password resolves to the empty string, which
// PropertiesLoginModule then accepts as this account's password (UNOMI-974). This endpoint
// authenticates against the karaf realm directly rather than through the REST
// AuthenticationFilter, so it needs its own refusal: it stays reachable on launch paths the
// startup guards in bin/setenv and the Docker entrypoint cannot cover, notably karaf.bat.
if (password.isEmpty()) {
LOGGER.warn("Rejecting health check Basic authentication with an empty password");
return false;
}

LOGGER.debug("Authenticating user {}", parts[0]);
LOGGER.debug("Authenticating user {}", user);
try {
//We use JAAS for authentication and authorization but it could be done using UserAdmin OSGI service
LOGGER.debug("Creating Login Context for realm {}", realm);
LoginContext loginContext = new LoginContext(realm, callbacks -> {
for (Callback callback : callbacks) {
if (callback instanceof NameCallback) {
((NameCallback) callback).setName(parts[0]);
((NameCallback) callback).setName(user);
} else if (callback instanceof PasswordCallback) {
((PasswordCallback) callback).setPassword(parts[1].toCharArray());
((PasswordCallback) callback).setPassword(password.toCharArray());
} else {
throw new UnsupportedCallbackException(callback);
}
Expand All @@ -106,6 +123,44 @@ protected boolean authenticated(HttpServletRequest request) {
return false;
}

/**
* Decodes a Basic {@code Authorization} header into {user, password}, or {@code null} when it is
* missing, not Basic, undecodable, or carries no {@code ':'} separator.
* <p>
* The split is bounded to two parts on purpose. {@code split(":")} discards trailing empty
* strings, so {@code "health:"} yielded a single element and blew up on {@code parts[1]}, while
* {@code "health::x"} yielded {@code ["health", "", "x"]} — an <em>empty</em> password that was
* handed straight to JAAS. Bounding it keeps the RFC 7617 rule that the password is everything
* after the first colon, and makes the emptiness check in {@link #authenticated} meaningful.
* <p>
* The scheme is matched case-insensitively per RFC 7235 §2.1. The previous implementation did a
* blind {@code substring(6)} with no prefix check at all, so it accepted {@code "basic "}; a
* case-sensitive check here would have quietly started rejecting those clients.
* <p>
* Neither returned element is ever {@code null}: {@link String#split(String, int)} only ever
* produces non-null substrings, and a result that is not exactly two elements is rejected above.
* Package-private for {@code HealthCheckHttpContextBlankPasswordTest}, which pins every one of
* these cases.
*/
String[] extractBasicCredentials(String authzHeader) {
if (authzHeader == null
|| authzHeader.length() < BASIC_PREFIX.length()
|| !authzHeader.regionMatches(true, 0, BASIC_PREFIX, 0, BASIC_PREFIX.length())) {
return null;
}
try {
String decoded = new String(Base64.getDecoder().decode(authzHeader.substring(BASIC_PREFIX.length()).trim()),
StandardCharsets.UTF_8);
String[] parts = decoded.split(":", 2);
return parts.length == 2 ? parts : null;
} catch (IllegalArgumentException e) {
// Undecodable base64. Deliberately not logged at error: this is attacker-controlled input
// and a malformed header is a client error, not a server fault.
LOGGER.debug("Could not decode Basic credentials");
return null;
}
}

public URL getResource(String s) {
return null;
}
Expand Down
Loading
Loading