001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *   http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing,
013 * software distributed under the License is distributed on an
014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
015 * KIND, either express or implied.  See the License for the
016 * specific language governing permissions and limitations
017 * under the License.
018 */
019package org.eclipse.aether.named.redisson;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.net.URI;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.nio.file.Paths;
027import java.util.Locale;
028
029import org.eclipse.aether.named.support.NamedLockFactorySupport;
030import org.redisson.Redisson;
031import org.redisson.api.RedissonClient;
032import org.redisson.config.Config;
033
034/**
035 * Support class for factories using {@link RedissonClient}.
036 */
037public abstract class RedissonNamedLockFactorySupport extends NamedLockFactorySupport {
038    protected static final String NAME_PREFIX = "maven:resolver:";
039
040    private static final String DEFAULT_CONFIG_FILE_NAME = "maven-resolver-redisson.yaml";
041
042    private static final String DEFAULT_CLIENT_NAME = "maven-resolver";
043
044    /**
045     * Path to a Redisson configuration file in YAML format. Read official documentation for details.
046     *
047     * @since 1.7.0
048     * @configurationSource {@link System#getProperty(String, String)}
049     * @configurationType {@link java.lang.String}
050     */
051    public static final String SYSTEM_PROP_CONFIG_FILE = "aether.syncContext.named.redisson.configFile";
052
053    /**
054     * Address of the Redis instance. Optional.
055     *
056     * @since 2.0.0
057     * @configurationSource {@link System#getProperty(String, String)}
058     * @configurationType {@link java.lang.String}
059     * @configurationDefaultValue {@link #DEFAULT_REDIS_ADDRESS}
060     */
061    public static final String SYSTEM_PROP_REDIS_ADDRESS = "aether.syncContext.named.redisson.address";
062
063    public static final String DEFAULT_REDIS_ADDRESS = "redis://localhost:6379";
064
065    /**
066     * Whether a plaintext {@code redis://} address pointing at a non-loopback host is allowed. Such a connection is
067     * unencrypted and unauthenticated at the transport, so a tampered, spoofed, or on-path-modified Redis can grant
068     * conflicting locks and corrupt the shared local repository the locks protect. Disabled by default; prefer a
069     * {@code rediss://} (TLS) address, or a Redisson configuration file with authentication, for anything
070     * cross-host.
071     *
072     * @since 2.0.23
073     * @configurationSource {@link System#getProperty(String, String)}
074     * @configurationType {@link java.lang.Boolean}
075     * @configurationDefaultValue false
076     */
077    public static final String SYSTEM_PROP_ALLOW_INSECURE_ADDRESS =
078            "aether.syncContext.named.redisson.allowInsecureAddress";
079
080    protected final RedissonClient redissonClient;
081
082    public RedissonNamedLockFactorySupport() {
083        this.redissonClient = createRedissonClient();
084    }
085
086    @Override
087    protected void doShutdown() {
088        logger.trace("Shutting down Redisson client with id '{}'", redissonClient.getId());
089        redissonClient.shutdown();
090    }
091
092    private RedissonClient createRedissonClient() {
093        Path configFilePath = null;
094
095        String configFile = System.getProperty(SYSTEM_PROP_CONFIG_FILE);
096        if (configFile != null && !configFile.isEmpty()) {
097            configFilePath = Paths.get(configFile);
098            if (Files.notExists(configFilePath)) {
099                throw new IllegalArgumentException(
100                        "The specified Redisson config file does not exist: " + configFilePath);
101            }
102        }
103
104        if (configFilePath == null) {
105            String mavenConf = System.getProperty("maven.conf");
106            if (mavenConf != null && !mavenConf.isEmpty()) {
107                configFilePath = Paths.get(mavenConf, DEFAULT_CONFIG_FILE_NAME);
108                if (Files.notExists(configFilePath)) {
109                    configFilePath = null;
110                }
111            }
112        }
113
114        Config config;
115
116        if (configFilePath != null) {
117            logger.trace("Reading Redisson config file from '{}'", configFilePath);
118            try (InputStream is = Files.newInputStream(configFilePath)) {
119                config = Config.fromYAML(is);
120            } catch (IOException e) {
121                throw new IllegalStateException("Failed to read Redisson config file: " + configFilePath, e);
122            }
123        } else {
124            config = new Config();
125            String defaultRedisAddress = System.getProperty(SYSTEM_PROP_REDIS_ADDRESS, DEFAULT_REDIS_ADDRESS);
126            if (isInsecureRemoteAddress(defaultRedisAddress)) {
127                if (Boolean.getBoolean(SYSTEM_PROP_ALLOW_INSECURE_ADDRESS)) {
128                    logger.warn(
129                            "Using plaintext Redis address '{}' for lock state guarding local repository writes;"
130                                    + " the connection is unencrypted and unauthenticated at the transport, so the"
131                                    + " endpoint and the network path to it must be trusted and isolated",
132                            defaultRedisAddress);
133                } else {
134                    throw new IllegalStateException("Refusing plaintext non-loopback Redis address '"
135                            + defaultRedisAddress + "': lock answers from a tampered or spoofed Redis can void"
136                            + " mutual exclusion and corrupt the shared local repository. Use a 'rediss://' (TLS)"
137                            + " address, or a Redisson configuration file ('" + SYSTEM_PROP_CONFIG_FILE
138                            + "') with authentication, or explicitly opt in with -D"
139                            + SYSTEM_PROP_ALLOW_INSECURE_ADDRESS + "=true");
140                }
141            }
142            config.useSingleServer().setAddress(defaultRedisAddress).setClientName(DEFAULT_CLIENT_NAME);
143        }
144
145        RedissonClient redissonClient = Redisson.create(config);
146        logger.trace("Created Redisson client with id '{}'", redissonClient.getId());
147
148        return redissonClient;
149    }
150
151    /**
152     * Returns {@code true} if the given address is a plaintext {@code redis://} address pointing at a non-loopback
153     * host. TLS ({@code rediss://}) addresses and loopback addresses are acceptable defaults; anything else is
154     * insecure. Unparseable addresses are treated as insecure (fail closed).
155     */
156    static boolean isInsecureRemoteAddress(String address) {
157        if (address.toLowerCase(Locale.ROOT).startsWith("rediss://")) {
158            return false; // TLS protects the channel
159        }
160        String host;
161        try {
162            host = URI.create(address).getHost();
163        } catch (IllegalArgumentException e) {
164            return true;
165        }
166        if (host == null) {
167            return true;
168        }
169        return !("localhost".equalsIgnoreCase(host)
170                || host.startsWith("127.")
171                || "::1".equals(host)
172                || "[::1]".equals(host));
173    }
174}