diff options
author | Samuel Holland <samuel@sholland.org> | 2018-09-05 20:17:14 -0500 |
---|---|---|
committer | Jason A. Donenfeld <Jason@zx2c4.com> | 2018-12-08 02:39:41 +0100 |
commit | d1e85633fbe8d871355d2b9feb51e2c9983d8a21 (patch) | |
tree | d95ad1ae84d02fc3e18a211aa1e1ef8150d8fa35 /app/src/main/java/com/wireguard/config | |
parent | a264f7ab36bf1335999d53cb4a0d753c54b231d0 (diff) |
Remodel the Model
- The configuration and crypto model is now entirely independent
of Android classes other than Nullable and TextUtils.
- Model classes are immutable and use builders that enforce the
appropriate optional/required attributes.
- The Android config proxies (for Parcelable and databinding) are
moved to the Android side of the codebase, and are designed to be
safe for two-way databinding. This allows proper observability in
TunnelDetailFragment.
- Various robustness fixes and documentation updates to helper classes.
Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
Diffstat (limited to 'app/src/main/java/com/wireguard/config')
8 files changed, 821 insertions, 852 deletions
diff --git a/app/src/main/java/com/wireguard/config/Attribute.java b/app/src/main/java/com/wireguard/config/Attribute.java index d4bdb6c8..d61cc744 100644 --- a/app/src/main/java/com/wireguard/config/Attribute.java +++ b/app/src/main/java/com/wireguard/config/Attribute.java @@ -1,94 +1,49 @@ /* - * Copyright © 2017-2018 WireGuard LLC. All Rights Reserved. + * Copyright © 2018 WireGuard LLC. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ package com.wireguard.config; -import android.annotation.SuppressLint; -import android.support.annotation.Nullable; import android.text.TextUtils; -import java.util.HashMap; -import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * The set of valid attributes for an interface or peer in a WireGuard configuration file. - */ - -public enum Attribute { - ADDRESS("Address"), - ALLOWED_IPS("AllowedIPs"), - DNS("DNS"), - EXCLUDED_APPLICATIONS("ExcludedApplications"), - ENDPOINT("Endpoint"), - LISTEN_PORT("ListenPort"), - MTU("MTU"), - PERSISTENT_KEEPALIVE("PersistentKeepalive"), - PRESHARED_KEY("PresharedKey"), - PRIVATE_KEY("PrivateKey"), - PUBLIC_KEY("PublicKey"); - - private static final String[] EMPTY_LIST = new String[0]; - private static final Map<String, Attribute> KEY_MAP; - private static final Pattern LIST_SEPARATOR_PATTERN = Pattern.compile("\\s*,\\s*"); - private static final Pattern SEPARATOR_PATTERN = Pattern.compile("\\s|="); - - static { - KEY_MAP = new HashMap<>(Attribute.values().length); - for (final Attribute key : Attribute.values()) { - KEY_MAP.put(key.token.toLowerCase(), key); - } - } +import java9.util.Optional; - private final Pattern pattern; - private final String token; +public final class Attribute { + private static final Pattern LINE_PATTERN = Pattern.compile("(\\w+)\\s*=\\s*([^\\s#][^#]*)"); + private static final Pattern LIST_SEPARATOR = Pattern.compile("\\s*,\\s*"); - Attribute(final String token) { - pattern = Pattern.compile(token + "\\s*=\\s*(\\S.*)"); - this.token = token; - } - - public static <T> String iterableToString(final Iterable<T> iterable) { - return TextUtils.join(", ", iterable); - } - - @Nullable - public static Attribute match(final CharSequence line) { - return KEY_MAP.get(SEPARATOR_PATTERN.split(line)[0].toLowerCase()); - } + private final String key; + private final String value; - public static String[] stringToList(@Nullable final String string) { - if (TextUtils.isEmpty(string)) - return EMPTY_LIST; - return LIST_SEPARATOR_PATTERN.split(string.trim()); + private Attribute(final String key, final String value) { + this.key = key; + this.value = value; } - @SuppressLint("DefaultLocale") - public String composeWith(@Nullable final Object value) { - return String.format("%s = %s%n", token, value); + public static String join(final Iterable<?> values) { + return TextUtils.join(", ", values); } - @SuppressLint("DefaultLocale") - public String composeWith(final int value) { - return String.format("%s = %d%n", token, value); + public static Optional<Attribute> parse(final CharSequence line) { + final Matcher matcher = LINE_PATTERN.matcher(line); + if (!matcher.matches()) + return Optional.empty(); + return Optional.of(new Attribute(matcher.group(1), matcher.group(2))); } - public <T> String composeWith(final Iterable<T> value) { - return String.format("%s = %s%n", token, iterableToString(value)); + public static String[] split(final CharSequence value) { + return LIST_SEPARATOR.split(value); } - @Nullable - public String parse(final CharSequence line) { - final Matcher matcher = pattern.matcher(line); - return matcher.matches() ? matcher.group(1) : null; + public String getKey() { + return key; } - @Nullable - public String[] parseList(final CharSequence line) { - final Matcher matcher = pattern.matcher(line); - return matcher.matches() ? stringToList(matcher.group(1)) : null; + public String getValue() { + return value; } } diff --git a/app/src/main/java/com/wireguard/config/Config.java b/app/src/main/java/com/wireguard/config/Config.java index 61e31838..7645583d 100644 --- a/app/src/main/java/com/wireguard/config/Config.java +++ b/app/src/main/java/com/wireguard/config/Config.java @@ -5,170 +5,193 @@ package com.wireguard.config; -import android.content.Context; -import android.databinding.BaseObservable; -import android.databinding.Bindable; -import android.databinding.ObservableArrayList; -import android.databinding.ObservableList; -import android.os.Parcel; -import android.os.Parcelable; import android.support.annotation.Nullable; -import com.android.databinding.library.baseAdapters.BR; -import com.wireguard.android.Application; -import com.wireguard.android.R; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.StringReader; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; /** - * Represents a wg-quick configuration file, its name, and its connection state. + * Represents the contents of a wg-quick configuration file, made up of one or more "Interface" + * sections (combined together), and zero or more "Peer" sections (treated individually). + * <p> + * Instances of this class are immutable. */ - -public class Config { - private final Interface interfaceSection = new Interface(); - private List<Peer> peers = new ArrayList<>(); - - public static Config from(final String string) throws IOException { - return from(new BufferedReader(new StringReader(string))); +public final class Config { + private final Interface interfaze; + private final List<Peer> peers; + + private Config(final Builder builder) { + interfaze = Objects.requireNonNull(builder.interfaze, "An [Interface] section is required"); + // Defensively copy to ensure immutability even if the Builder is reused. + peers = Collections.unmodifiableList(new ArrayList<>(builder.peers)); } - public static Config from(final InputStream stream) throws IOException { - return from(new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))); - } - - public static Config from(final BufferedReader reader) throws IOException { - final Config config = new Config(); - final Context context = Application.get(); - Peer currentPeer = null; - String line; - boolean inInterfaceSection = false; - while ((line = reader.readLine()) != null) { - final int commentIndex = line.indexOf('#'); - if (commentIndex != -1) - line = line.substring(0, commentIndex); - line = line.trim(); - if (line.isEmpty()) - continue; - if ("[Interface]".toLowerCase().equals(line.toLowerCase())) { - currentPeer = null; - inInterfaceSection = true; - } else if ("[Peer]".toLowerCase().equals(line.toLowerCase())) { - currentPeer = new Peer(); - config.peers.add(currentPeer); - inInterfaceSection = false; - } else if (inInterfaceSection) { - config.interfaceSection.parse(line); - } else if (currentPeer != null) { - currentPeer.parse(line); - } else { - throw new IllegalArgumentException(context.getString(R.string.tunnel_error_invalid_config_line, line)); + /** + * Parses an series of "Interface" and "Peer" sections into a {@code Config}. Throws + * {@link ParseException} if the input is not well-formed or contains unparseable sections. + * + * @param stream a stream of UTF-8 text that is interpreted as a WireGuard configuration file + * @return a {@code Config} instance representing the supplied configuration + */ + public static Config parse(final InputStream stream) throws IOException, ParseException { + final Builder builder = new Builder(); + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + final Collection<String> interfaceLines = new ArrayList<>(); + final Collection<String> peerLines = new ArrayList<>(); + boolean inInterfaceSection = false; + boolean inPeerSection = false; + @Nullable String line; + while ((line = reader.readLine()) != null) { + final int commentIndex = line.indexOf('#'); + if (commentIndex != -1) + line = line.substring(0, commentIndex); + line = line.trim(); + if (line.isEmpty()) + continue; + if (line.startsWith("[")) { + // Consume all [Peer] lines read so far. + if (inPeerSection) { + builder.parsePeer(peerLines); + peerLines.clear(); + } + if ("[Interface]".equalsIgnoreCase(line)) { + inInterfaceSection = true; + inPeerSection = false; + } else if ("[Peer]".equalsIgnoreCase(line)) { + inInterfaceSection = false; + inPeerSection = true; + } else { + throw new ParseException("top level", line, "Unknown section name"); + } + } else if (inInterfaceSection) { + interfaceLines.add(line); + } else if (inPeerSection) { + peerLines.add(line); + } else { + throw new ParseException("top level", line, "Expected [Interface] or [Peer]"); + } } + if (inPeerSection) + builder.parsePeer(peerLines); + else if (!inInterfaceSection) + throw new ParseException("top level", "", "Empty configuration"); + // Combine all [Interface] sections in the file. + builder.parseInterface(interfaceLines); } - if (!inInterfaceSection && currentPeer == null) { - throw new IllegalArgumentException(context.getString(R.string.tunnel_error_no_config_information)); - } - return config; + return builder.build(); } + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Config)) + return false; + final Config other = (Config) obj; + return interfaze.equals(other.interfaze) && peers.equals(other.peers); + } + + /** + * Returns the interface section of the configuration. + * + * @return the interface configuration + */ public Interface getInterface() { - return interfaceSection; + return interfaze; } + /** + * Returns a list of the configuration's peer sections. + * + * @return a list of {@link Peer}s + */ public List<Peer> getPeers() { return peers; } @Override + public int hashCode() { + return 31 * interfaze.hashCode() + peers.hashCode(); + } + + /** + * Converts the {@code Config} into a string suitable for debugging purposes. The {@code Config} + * is identified by its interface's public key and the number of peers it has. + * + * @return a concise single-line identifier for the {@code Config} + */ + @Override public String toString() { - final StringBuilder sb = new StringBuilder().append(interfaceSection); + return "(Config " + interfaze + " (" + peers.size() + " peers))"; + } + + /** + * Converts the {@code Config} into a string suitable for use as a {@code wg-quick} + * configuration file. + * + * @return the {@code Config} represented as one [Interface] and zero or more [Peer] sections + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + sb.append("[Interface]\n").append(interfaze.toWgQuickString()); for (final Peer peer : peers) - sb.append('\n').append(peer); + sb.append("\n[Peer]\n").append(peer.toWgQuickString()); return sb.toString(); } - public static class Observable extends BaseObservable implements Parcelable { - public static final Creator<Observable> CREATOR = new Creator<Observable>() { - @Override - public Observable createFromParcel(final Parcel in) { - return new Observable(in); - } - - @Override - public Observable[] newArray(final int size) { - return new Observable[size]; - } - }; - private final Interface.Observable observableInterface; - private final ObservableList<Peer.Observable> observablePeers; - @Nullable private String name; - - public Observable(@Nullable final Config parent, @Nullable final String name) { - this.name = name; - - observableInterface = new Interface.Observable(parent == null ? null : parent.interfaceSection); - observablePeers = new ObservableArrayList<>(); - if (parent != null) { - for (final Peer peer : parent.getPeers()) - observablePeers.add(new Peer.Observable(peer)); - } - } - - private Observable(final Parcel in) { - name = in.readString(); - observableInterface = in.readParcelable(Interface.Observable.class.getClassLoader()); - observablePeers = new ObservableArrayList<>(); - in.readTypedList(observablePeers, Peer.Observable.CREATOR); - } + /** + * Serializes the {@code Config} for use with the WireGuard cross-platform userspace API. + * + * @return the {@code Config} represented as a series of "key=value" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + sb.append(interfaze.toWgUserspaceString()); + sb.append("replace_peers=true\n"); + for (final Peer peer : peers) + sb.append(peer.toWgUserspaceString()); + return sb.toString(); + } - public void commitData(final Config parent) { - observableInterface.commitData(parent.interfaceSection); - final List<Peer> newPeers = new ArrayList<>(observablePeers.size()); - for (final Peer.Observable observablePeer : observablePeers) { - final Peer peer = new Peer(); - observablePeer.commitData(peer); - newPeers.add(peer); - } - parent.peers = newPeers; - notifyChange(); - } + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // Defaults to an empty set. + private final Set<Peer> peers = new LinkedHashSet<>(); + // No default; must be provided before building. + @Nullable private Interface interfaze; - @Override - public int describeContents() { - return 0; + public Builder addPeer(final Peer peer) { + peers.add(peer); + return this; } - @Bindable - public Interface.Observable getInterfaceSection() { - return observableInterface; + public Builder addPeers(final Collection<Peer> peers) { + this.peers.addAll(peers); + return this; } - @Bindable - public String getName() { - return name == null ? "" : name; + public Config build() { + return new Config(this); } - @Bindable - public ObservableList<Peer.Observable> getPeers() { - return observablePeers; + public Builder parseInterface(final Iterable<? extends CharSequence> lines) throws ParseException { + return setInterface(Interface.parse(lines)); } - public void setName(final String name) { - this.name = name; - notifyPropertyChanged(BR.name); + public Builder parsePeer(final Iterable<? extends CharSequence> lines) throws ParseException { + return addPeer(Peer.parse(lines)); } - @Override - public void writeToParcel(final Parcel dest, final int flags) { - dest.writeString(name); - dest.writeParcelable(observableInterface, flags); - dest.writeTypedList(observablePeers); + public Builder setInterface(final Interface interfaze) { + this.interfaze = interfaze; + return this; } } } diff --git a/app/src/main/java/com/wireguard/config/InetAddresses.java b/app/src/main/java/com/wireguard/config/InetAddresses.java index c50c5a0e..989598da 100644 --- a/app/src/main/java/com/wireguard/config/InetAddresses.java +++ b/app/src/main/java/com/wireguard/config/InetAddresses.java @@ -5,21 +5,22 @@ package com.wireguard.config; -import android.support.annotation.Nullable; - -import com.wireguard.android.Application; -import com.wireguard.android.R; - import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.net.Inet4Address; +import java.net.Inet6Address; import java.net.InetAddress; +/** + * Utility methods for creating instances of {@link InetAddress}. + */ public final class InetAddresses { private static final Method PARSER_METHOD; static { try { // This method is only present on Android. + // noinspection JavaReflectionMemberAccess PARSER_METHOD = InetAddress.class.getMethod("parseNumericAddress", String.class); } catch (final NoSuchMethodException e) { throw new RuntimeException(e); @@ -30,13 +31,23 @@ public final class InetAddresses { // Prevent instantiation. } - public static InetAddress parse(@Nullable final String address) { - if (address == null || address.isEmpty()) - throw new IllegalArgumentException(Application.get().getString(R.string.tunnel_error_empty_inetaddress)); + /** + * Parses a numeric IPv4 or IPv6 address without performing any DNS lookups. + * + * @param address a string representing the IP address + * @return an instance of {@link Inet4Address} or {@link Inet6Address}, as appropriate + */ + public static InetAddress parse(final String address) { + if (address.isEmpty()) + throw new IllegalArgumentException("Empty address"); try { return (InetAddress) PARSER_METHOD.invoke(null, address); } catch (final IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e.getCause() == null ? e : e.getCause()); + final Throwable cause = e.getCause(); + // Re-throw parsing exceptions with the original type, as callers might try to catch + // them. On the other hand, callers cannot be expected to handle reflection failures. + throw cause instanceof IllegalArgumentException ? + (IllegalArgumentException) cause : new RuntimeException(e); } } } diff --git a/app/src/main/java/com/wireguard/config/InetEndpoint.java b/app/src/main/java/com/wireguard/config/InetEndpoint.java index 3efe4203..06d0ca80 100644 --- a/app/src/main/java/com/wireguard/config/InetEndpoint.java +++ b/app/src/main/java/com/wireguard/config/InetEndpoint.java @@ -5,36 +5,68 @@ package com.wireguard.config; -import android.annotation.SuppressLint; +import android.support.annotation.Nullable; -import com.wireguard.android.Application; -import com.wireguard.android.R; +import org.threeten.bp.Duration; +import org.threeten.bp.Instant; import java.net.Inet4Address; -import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; +import java.util.regex.Pattern; -import javax.annotation.Nullable; +import java9.util.Optional; + + +/** + * An external endpoint (host and port) used to connect to a WireGuard {@link Peer}. + * <p> + * Instances of this class are externally immutable. + */ +public final class InetEndpoint { + private static final Pattern BARE_IPV6 = Pattern.compile("^[^\\[]*:"); + private static final Pattern FORBIDDEN_CHARACTERS = Pattern.compile("[/?#]"); -public class InetEndpoint { private final String host; + private final boolean isResolved; + private final Object lock = new Object(); private final int port; - @Nullable private InetAddress resolvedHost; + private Instant lastResolution = Instant.EPOCH; + @Nullable private InetEndpoint resolved; - public InetEndpoint(@Nullable final String endpoint) { - if (endpoint.indexOf('/') != -1 || endpoint.indexOf('?') != -1 || endpoint.indexOf('#') != -1) - throw new IllegalArgumentException(Application.get().getString(R.string.tunnel_error_forbidden_endpoint_chars)); + private InetEndpoint(final String host, final boolean isResolved, final int port) { + this.host = host; + this.isResolved = isResolved; + this.port = port; + } + + public static InetEndpoint parse(final String endpoint) { + if (FORBIDDEN_CHARACTERS.matcher(endpoint).find()) + throw new IllegalArgumentException("Forbidden characters in Endpoint"); final URI uri; try { uri = new URI("wg://" + endpoint); } catch (final URISyntaxException e) { throw new IllegalArgumentException(e); } - host = uri.getHost(); - port = uri.getPort(); + try { + InetAddresses.parse(uri.getHost()); + // Parsing ths host as a numeric address worked, so we don't need to do DNS lookups. + return new InetEndpoint(uri.getHost(), true, uri.getPort()); + } catch (final IllegalArgumentException ignored) { + // Failed to parse the host as a numeric address, so it must be a DNS hostname/FQDN. + return new InetEndpoint(uri.getHost(), false, uri.getPort()); + } + } + + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof InetEndpoint)) + return false; + final InetEndpoint other = (InetEndpoint) obj; + return host.equals(other.host) && port == other.port; } public String getHost() { @@ -45,28 +77,47 @@ public class InetEndpoint { return port; } - @SuppressLint("DefaultLocale") - public String getResolvedEndpoint() throws UnknownHostException { - if (resolvedHost == null) { - final InetAddress[] candidates = InetAddress.getAllByName(host); - if (candidates.length == 0) - throw new UnknownHostException(host); - for (final InetAddress addr : candidates) { - if (addr instanceof Inet4Address) { - resolvedHost = addr; - break; + /** + * Generate an {@code InetEndpoint} instance with the same port and the host resolved using DNS + * to a numeric address. If the host is already numeric, the existing instance may be returned. + * Because this function may perform network I/O, it must not be called from the main thread. + * + * @return the resolved endpoint, or {@link Optional#empty()} + */ + public Optional<InetEndpoint> getResolved() { + if (isResolved) + return Optional.of(this); + synchronized (lock) { + //TODO(zx2c4): Implement a real timeout mechanism using DNS TTL + if (Duration.between(lastResolution, Instant.now()).toMinutes() > 1) { + try { + // Prefer v4 endpoints over v6 to work around DNS64 and IPv6 NAT issues. + final InetAddress[] candidates = InetAddress.getAllByName(host); + InetAddress address = candidates[0]; + for (final InetAddress candidate : candidates) { + if (candidate instanceof Inet4Address) { + address = candidate; + break; + } + } + resolved = new InetEndpoint(address.getHostAddress(), true, port); + lastResolution = Instant.now(); + } catch (final UnknownHostException e) { + resolved = null; } } - if (resolvedHost == null) - resolvedHost = candidates[0]; + return Optional.ofNullable(resolved); } - return String.format(resolvedHost instanceof Inet6Address ? - "[%s]:%d" : "%s:%d", resolvedHost.getHostAddress(), port); } - @SuppressLint("DefaultLocale") - public String getEndpoint() { - return String.format(host.contains(":") && !host.contains("[") ? - "[%s]:%d" : "%s:%d", host, port); + @Override + public int hashCode() { + return host.hashCode() ^ port; + } + + @Override + public String toString() { + final boolean isBareIpv6 = isResolved && BARE_IPV6.matcher(host).matches(); + return (isBareIpv6 ? '[' + host + ']' : host) + ':' + port; } } diff --git a/app/src/main/java/com/wireguard/config/InetNetwork.java b/app/src/main/java/com/wireguard/config/InetNetwork.java index 836a1335..9e5e8c64 100644 --- a/app/src/main/java/com/wireguard/config/InetNetwork.java +++ b/app/src/main/java/com/wireguard/config/InetNetwork.java @@ -7,26 +7,36 @@ package com.wireguard.config; import java.net.Inet4Address; import java.net.InetAddress; -import java.util.Objects; -public class InetNetwork { +/** + * An Internet network, denoted by its address and netmask + * <p> + * Instances of this class are immutable. + */ +public final class InetNetwork { private final InetAddress address; private final int mask; - public InetNetwork(final String input) { - final int slash = input.lastIndexOf('/'); + private InetNetwork(final InetAddress address, final int mask) { + this.address = address; + this.mask = mask; + } + + public static InetNetwork parse(final String network) { + final int slash = network.lastIndexOf('/'); final int rawMask; final String rawAddress; if (slash >= 0) { - rawMask = Integer.parseInt(input.substring(slash + 1), 10); - rawAddress = input.substring(0, slash); + rawMask = Integer.parseInt(network.substring(slash + 1), 10); + rawAddress = network.substring(0, slash); } else { rawMask = -1; - rawAddress = input; + rawAddress = network; } - address = InetAddresses.parse(rawAddress); + final InetAddress address = InetAddresses.parse(rawAddress); final int maxMask = (address instanceof Inet4Address) ? 32 : 128; - mask = rawMask >= 0 && rawMask <= maxMask ? rawMask : maxMask; + final int mask = rawMask >= 0 && rawMask <= maxMask ? rawMask : maxMask; + return new InetNetwork(address, mask); } @Override @@ -34,7 +44,7 @@ public class InetNetwork { if (!(obj instanceof InetNetwork)) return false; final InetNetwork other = (InetNetwork) obj; - return Objects.equals(address, other.address) && mask == other.mask; + return address.equals(other.address) && mask == other.mask; } public InetAddress getAddress() { diff --git a/app/src/main/java/com/wireguard/config/Interface.java b/app/src/main/java/com/wireguard/config/Interface.java index aa1d986b..dc1a291d 100644 --- a/app/src/main/java/com/wireguard/config/Interface.java +++ b/app/src/main/java/com/wireguard/config/Interface.java @@ -5,395 +5,345 @@ package com.wireguard.config; -import android.content.Context; -import android.databinding.BaseObservable; -import android.databinding.Bindable; -import android.os.Parcel; -import android.os.Parcelable; import android.support.annotation.Nullable; -import com.wireguard.android.Application; -import com.wireguard.android.BR; -import com.wireguard.android.R; -import com.wireguard.crypto.Keypair; +import com.wireguard.crypto.Key; +import com.wireguard.crypto.KeyPair; import java.net.InetAddress; -import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; + +import java9.util.Lists; +import java9.util.Optional; +import java9.util.stream.Collectors; +import java9.util.stream.Stream; +import java9.util.stream.StreamSupport; /** - * Represents the configuration for a WireGuard interface (an [Interface] block). + * Represents the configuration for a WireGuard interface (an [Interface] block). Interfaces must + * have a private key (used to initialize a {@code KeyPair}), and may optionally have several other + * attributes. + * <p> + * Instances of this class are immutable. */ - -public class Interface { - private final List<InetNetwork> addressList; - private final Context context = Application.get(); - private final List<InetAddress> dnsList; - private final List<String> excludedApplications; - @Nullable private Keypair keypair; - private int listenPort; - private int mtu; - - public Interface() { - addressList = new ArrayList<>(); - dnsList = new ArrayList<>(); - excludedApplications = new ArrayList<>(); +public final class Interface { + private static final int MAX_UDP_PORT = 65535; + private static final int MIN_UDP_PORT = 0; + + private final Set<InetNetwork> addresses; + private final Set<InetAddress> dnsServers; + private final Set<String> excludedApplications; + private final KeyPair keyPair; + private final Optional<Integer> listenPort; + private final Optional<Integer> mtu; + + private Interface(final Builder builder) { + // Defensively copy to ensure immutability even if the Builder is reused. + addresses = Collections.unmodifiableSet(new LinkedHashSet<>(builder.addresses)); + dnsServers = Collections.unmodifiableSet(new LinkedHashSet<>(builder.dnsServers)); + excludedApplications = Collections.unmodifiableSet(new LinkedHashSet<>(builder.excludedApplications)); + keyPair = Objects.requireNonNull(builder.keyPair, "Interfaces must have a private key"); + listenPort = builder.listenPort; + mtu = builder.mtu; } - private void addAddresses(@Nullable final String[] addresses) { - if (addresses != null && addresses.length > 0) { - for (final String addr : addresses) { - if (addr.isEmpty()) - throw new IllegalArgumentException(context.getString(R.string.tunnel_error_empty_interface_address)); - addressList.add(new InetNetwork(addr)); + /** + * Parses an series of "KEY = VALUE" lines into an {@code Interface}. Throws + * {@link ParseException} if the input is not well-formed or contains unknown attributes. + * + * @param lines An iterable sequence of lines, containing at least a private key attribute + * @return An {@code Interface} with all of the attributes from {@code lines} set + */ + public static Interface parse(final Iterable<? extends CharSequence> lines) throws ParseException { + final Builder builder = new Builder(); + for (final CharSequence line : lines) { + final Attribute attribute = Attribute.parse(line) + .orElseThrow(() -> new ParseException("[Interface]", line, "Syntax error")); + switch (attribute.getKey().toLowerCase()) { + case "address": + builder.parseAddresses(attribute.getValue()); + break; + case "dns": + builder.parseDnsServers(attribute.getValue()); + break; + case "excludedapplications": + builder.parseExcludedApplications(attribute.getValue()); + break; + case "listenport": + builder.parseListenPort(attribute.getValue()); + break; + case "mtu": + builder.parseMtu(attribute.getValue()); + break; + case "privatekey": + builder.parsePrivateKey(attribute.getValue()); + break; + default: + throw new ParseException("[Interface]", attribute.getKey(), "Unknown attribute"); } } + return builder.build(); } - private void addDnses(@Nullable final String[] dnses) { - if (dnses != null && dnses.length > 0) { - for (final String dns : dnses) { - dnsList.add(InetAddresses.parse(dns)); - } - } - } - - private void addExcludedApplications(@Nullable final String[] applications) { - if (applications != null && applications.length > 0) { - excludedApplications.addAll(Arrays.asList(applications)); - } - } - - @Nullable - private String getAddressString() { - if (addressList.isEmpty()) - return null; - return Attribute.iterableToString(addressList); - } - - public InetNetwork[] getAddresses() { - return addressList.toArray(new InetNetwork[addressList.size()]); - } - - @Nullable - private String getDnsString() { - if (dnsList.isEmpty()) - return null; - return Attribute.iterableToString(getDnsStrings()); + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Interface)) + return false; + final Interface other = (Interface) obj; + return addresses.equals(other.addresses) + && dnsServers.equals(other.dnsServers) + && excludedApplications.equals(other.excludedApplications) + && keyPair.equals(other.keyPair) + && listenPort.equals(other.listenPort) + && mtu.equals(other.mtu); } - private List<String> getDnsStrings() { - final List<String> strings = new ArrayList<>(); - for (final InetAddress addr : dnsList) - strings.add(addr.getHostAddress()); - return strings; + /** + * Returns the set of IP addresses assigned to the interface. + * + * @return a set of {@link InetNetwork}s + */ + public Set<InetNetwork> getAddresses() { + // The collection is already immutable. + return addresses; } - public InetAddress[] getDnses() { - return dnsList.toArray(new InetAddress[dnsList.size()]); + /** + * Returns the set of DNS servers associated with the interface. + * + * @return a set of {@link InetAddress}es + */ + public Set<InetAddress> getDnsServers() { + // The collection is already immutable. + return dnsServers; } - public String[] getExcludedApplications() { - return excludedApplications.toArray(new String[excludedApplications.size()]); + /** + * Returns the set of applications excluded from using the interface. + * + * @return a set of package names + */ + public Set<String> getExcludedApplications() { + // The collection is already immutable. + return excludedApplications; } - @Nullable - private String getExcludedApplicationsString() { - if (excludedApplications.isEmpty()) - return null; - return Attribute.iterableToString(excludedApplications); + /** + * Returns the public/private key pair used by the interface. + * + * @return a key pair + */ + public KeyPair getKeyPair() { + return keyPair; } - public int getListenPort() { + /** + * Returns the UDP port number that the WireGuard interface will listen on. + * + * @return a UDP port number, or {@code Optional.empty()} if none is configured + */ + public Optional<Integer> getListenPort() { return listenPort; } - @Nullable - private String getListenPortString() { - if (listenPort == 0) - return null; - return Integer.valueOf(listenPort).toString(); - } - - public int getMtu() { + /** + * Returns the MTU used for the WireGuard interface. + * + * @return the MTU, or {@code Optional.empty()} if none is configured + */ + public Optional<Integer> getMtu() { return mtu; } - @Nullable - private String getMtuString() { - if (mtu == 0) - return null; - return Integer.toString(mtu); - } - - @Nullable - public String getPrivateKey() { - if (keypair == null) - return null; - return keypair.getPrivateKey(); - } - - @Nullable - public String getPublicKey() { - if (keypair == null) - return null; - return keypair.getPublicKey(); - } - - public void parse(final String line) { - final Attribute key = Attribute.match(line); - if (key == null) - throw new IllegalArgumentException(String.format(context.getString(R.string.tunnel_error_interface_parse_failed), line)); - switch (key) { - case ADDRESS: - addAddresses(key.parseList(line)); - break; - case DNS: - addDnses(key.parseList(line)); - break; - case EXCLUDED_APPLICATIONS: - addExcludedApplications(key.parseList(line)); - break; - case LISTEN_PORT: - setListenPortString(key.parse(line)); - break; - case MTU: - setMtuString(key.parse(line)); - break; - case PRIVATE_KEY: - setPrivateKey(key.parse(line)); - break; - default: - throw new IllegalArgumentException(line); - } - } - - private void setAddressString(@Nullable final String addressString) { - addressList.clear(); - addAddresses(Attribute.stringToList(addressString)); - } - - private void setDnsString(@Nullable final String dnsString) { - dnsList.clear(); - addDnses(Attribute.stringToList(dnsString)); - } - - private void setExcludedApplicationsString(@Nullable final String applicationsString) { - excludedApplications.clear(); - addExcludedApplications(Attribute.stringToList(applicationsString)); - } - - private void setListenPort(final int listenPort) { - this.listenPort = listenPort; - } - - private void setListenPortString(@Nullable final String port) { - if (port != null && !port.isEmpty()) - setListenPort(Integer.parseInt(port, 10)); - else - setListenPort(0); - } - - private void setMtu(final int mtu) { - this.mtu = mtu; - } - - private void setMtuString(@Nullable final String mtu) { - if (mtu != null && !mtu.isEmpty()) - setMtu(Integer.parseInt(mtu, 10)); - else - setMtu(0); - } - - private void setPrivateKey(@Nullable String privateKey) { - if (privateKey != null && privateKey.isEmpty()) - privateKey = null; - keypair = privateKey == null ? null : new Keypair(privateKey); + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + addresses.hashCode(); + hash = 31 * hash + dnsServers.hashCode(); + hash = 31 * hash + excludedApplications.hashCode(); + hash = 31 * hash + keyPair.hashCode(); + hash = 31 * hash + listenPort.hashCode(); + hash = 31 * hash + mtu.hashCode(); + return hash; } + /** + * Converts the {@code Interface} into a string suitable for debugging purposes. The {@code + * Interface} is identified by its public key and (if set) the port used for its UDP socket. + * + * @return A concise single-line identifier for the {@code Interface} + */ @Override public String toString() { - final StringBuilder sb = new StringBuilder().append("[Interface]\n"); - if (!addressList.isEmpty()) - sb.append(Attribute.ADDRESS.composeWith(addressList)); - if (!dnsList.isEmpty()) - sb.append(Attribute.DNS.composeWith(getDnsStrings())); - if (!excludedApplications.isEmpty()) - sb.append(Attribute.EXCLUDED_APPLICATIONS.composeWith(excludedApplications)); - if (listenPort != 0) - sb.append(Attribute.LISTEN_PORT.composeWith(listenPort)); - if (mtu != 0) - sb.append(Attribute.MTU.composeWith(mtu)); - if (keypair != null) - sb.append(Attribute.PRIVATE_KEY.composeWith(keypair.getPrivateKey())); + final StringBuilder sb = new StringBuilder("(Interface "); + sb.append(keyPair.getPublicKey().toBase64()); + listenPort.ifPresent(lp -> sb.append(" @").append(lp)); + sb.append(')'); return sb.toString(); } - public static class Observable extends BaseObservable implements Parcelable { - public static final Creator<Observable> CREATOR = new Creator<Observable>() { - @Override - public Observable createFromParcel(final Parcel in) { - return new Observable(in); - } - - @Override - public Observable[] newArray(final int size) { - return new Observable[size]; - } - }; - @Nullable private String addresses; - @Nullable private String dnses; - @Nullable private String excludedApplications; - @Nullable private String listenPort; - @Nullable private String mtu; - @Nullable private String privateKey; - @Nullable private String publicKey; - - public Observable(@Nullable final Interface parent) { - if (parent != null) - loadData(parent); - } - - private Observable(final Parcel in) { - addresses = in.readString(); - dnses = in.readString(); - publicKey = in.readString(); - privateKey = in.readString(); - listenPort = in.readString(); - mtu = in.readString(); - excludedApplications = in.readString(); - } - - public void commitData(final Interface parent) { - parent.setAddressString(addresses); - parent.setDnsString(dnses); - parent.setExcludedApplicationsString(excludedApplications); - parent.setPrivateKey(privateKey); - parent.setListenPortString(listenPort); - parent.setMtuString(mtu); - loadData(parent); - notifyChange(); - } - - @Override - public int describeContents() { - return 0; + /** + * Converts the {@code Interface} into a string suitable for inclusion in a {@code wg-quick} + * configuration file. + * + * @return The {@code Interface} represented as a series of "Key = Value" lines + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + if (!addresses.isEmpty()) + sb.append("Address = ").append(Attribute.join(addresses)).append('\n'); + if (!dnsServers.isEmpty()) { + final List<String> dnsServerStrings = StreamSupport.stream(dnsServers) + .map(InetAddress::getHostAddress) + .collect(Collectors.toUnmodifiableList()); + sb.append("DNS = ").append(Attribute.join(dnsServerStrings)).append('\n'); } + if (!excludedApplications.isEmpty()) + sb.append("ExcludedApplications = ").append(Attribute.join(excludedApplications)).append('\n'); + listenPort.ifPresent(lp -> sb.append("ListenPort = ").append(lp).append('\n')); + mtu.ifPresent(m -> sb.append("MTU = ").append(m).append('\n')); + sb.append("PrivateKey = ").append(keyPair.getPrivateKey().toBase64()).append('\n'); + return sb.toString(); + } - public void generateKeypair() { - final Keypair keypair = new Keypair(); - privateKey = keypair.getPrivateKey(); - publicKey = keypair.getPublicKey(); - notifyPropertyChanged(BR.privateKey); - notifyPropertyChanged(BR.publicKey); - } + /** + * Serializes the {@code Interface} for use with the WireGuard cross-platform userspace API. + * Note that not all attributes are included in this representation. + * + * @return the {@code Interface} represented as a series of "KEY=VALUE" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + sb.append("private_key=").append(keyPair.getPrivateKey().toHex()).append('\n'); + listenPort.ifPresent(lp -> sb.append("listen_port=").append(lp).append('\n')); + return sb.toString(); + } - @Nullable - @Bindable - public String getAddresses() { - return addresses; + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // Defaults to an empty set. + private final Set<InetNetwork> addresses = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set<InetAddress> dnsServers = new LinkedHashSet<>(); + // Defaults to an empty set. + private final Set<String> excludedApplications = new LinkedHashSet<>(); + // No default; must be provided before building. + @Nullable private KeyPair keyPair; + // Defaults to not present. + private Optional<Integer> listenPort = Optional.empty(); + // Defaults to not present. + private Optional<Integer> mtu = Optional.empty(); + + public Builder addAddress(final InetNetwork address) { + addresses.add(address); + return this; } - @Nullable - @Bindable - public String getDnses() { - return dnses; + public Builder addAddresses(final Collection<InetNetwork> addresses) { + this.addresses.addAll(addresses); + return this; } - @Nullable - @Bindable - public String getExcludedApplications() { - return excludedApplications; + public Builder addDnsServer(final InetAddress dnsServer) { + dnsServers.add(dnsServer); + return this; } - @Bindable - public int getExcludedApplicationsCount() { - return Attribute.stringToList(excludedApplications).length; + public Builder addDnsServers(final Collection<? extends InetAddress> dnsServers) { + this.dnsServers.addAll(dnsServers); + return this; } - @Nullable - @Bindable - public String getListenPort() { - return listenPort; + public Interface build() { + return new Interface(this); } - @Nullable - @Bindable - public String getMtu() { - return mtu; + public Builder excludeApplication(final String application) { + excludedApplications.add(application); + return this; } - @Nullable - @Bindable - public String getPrivateKey() { - return privateKey; + public Builder excludeApplications(final Collection<String> applications) { + excludedApplications.addAll(applications); + return this; } - @Nullable - @Bindable - public String getPublicKey() { - return publicKey; + public Builder parseAddresses(final CharSequence addresses) throws ParseException { + try { + final List<InetNetwork> parsed = Stream.of(Attribute.split(addresses)) + .map(InetNetwork::parse) + .collect(Collectors.toUnmodifiableList()); + return addAddresses(parsed); + } catch (final IllegalArgumentException e) { + throw new ParseException("Address", addresses, e); + } } - private void loadData(final Interface parent) { - addresses = parent.getAddressString(); - dnses = parent.getDnsString(); - excludedApplications = parent.getExcludedApplicationsString(); - publicKey = parent.getPublicKey(); - privateKey = parent.getPrivateKey(); - listenPort = parent.getListenPortString(); - mtu = parent.getMtuString(); + public Builder parseDnsServers(final CharSequence dnsServers) throws ParseException { + try { + final List<InetAddress> parsed = Stream.of(Attribute.split(dnsServers)) + .map(InetAddresses::parse) + .collect(Collectors.toUnmodifiableList()); + return addDnsServers(parsed); + } catch (final IllegalArgumentException e) { + throw new ParseException("DNS", dnsServers, e); + } } - public void setAddresses(final String addresses) { - this.addresses = addresses; - notifyPropertyChanged(BR.addresses); + public Builder parseExcludedApplications(final CharSequence apps) throws ParseException { + try { + return excludeApplications(Lists.of(Attribute.split(apps))); + } catch (final IllegalArgumentException e) { + throw new ParseException("ExcludedApplications", apps, e); + } } - public void setDnses(final String dnses) { - this.dnses = dnses; - notifyPropertyChanged(BR.dnses); + public Builder parseListenPort(final String listenPort) throws ParseException { + try { + return setListenPort(Integer.parseInt(listenPort)); + } catch (final IllegalArgumentException e) { + throw new ParseException("ListenPort", listenPort, e); + } } - public void setExcludedApplications(final String excludedApplications) { - this.excludedApplications = excludedApplications; - notifyPropertyChanged(BR.excludedApplications); - notifyPropertyChanged(BR.excludedApplicationsCount); + public Builder parseMtu(final String mtu) throws ParseException { + try { + return setMtu(Integer.parseInt(mtu)); + } catch (final IllegalArgumentException e) { + throw new ParseException("MTU", mtu, e); + } } - public void setListenPort(final String listenPort) { - this.listenPort = listenPort; - notifyPropertyChanged(BR.listenPort); + public Builder parsePrivateKey(final String privateKey) throws ParseException { + try { + return setKeyPair(new KeyPair(Key.fromBase64(privateKey))); + } catch (final Key.KeyFormatException e) { + throw new ParseException("PrivateKey", "(omitted)", e); + } } - public void setMtu(final String mtu) { - this.mtu = mtu; - notifyPropertyChanged(BR.mtu); + public Builder setKeyPair(final KeyPair keyPair) { + this.keyPair = keyPair; + return this; } - public void setPrivateKey(final String privateKey) { - this.privateKey = privateKey; - - try { - publicKey = new Keypair(privateKey).getPublicKey(); - } catch (final IllegalArgumentException ignored) { - publicKey = ""; - } - - notifyPropertyChanged(BR.privateKey); - notifyPropertyChanged(BR.publicKey); + public Builder setListenPort(final int listenPort) { + if (listenPort < MIN_UDP_PORT || listenPort > MAX_UDP_PORT) + throw new IllegalArgumentException("ListenPort must be a valid UDP port number"); + this.listenPort = listenPort == 0 ? Optional.empty() : Optional.of(listenPort); + return this; } - @Override - public void writeToParcel(final Parcel dest, final int flags) { - dest.writeString(addresses); - dest.writeString(dnses); - dest.writeString(publicKey); - dest.writeString(privateKey); - dest.writeString(listenPort); - dest.writeString(mtu); - dest.writeString(excludedApplications); + public Builder setMtu(final int mtu) { + if (mtu < 0) + throw new IllegalArgumentException("MTU must not be negative"); + this.mtu = mtu == 0 ? Optional.empty() : Optional.of(mtu); + return this; } } } diff --git a/app/src/main/java/com/wireguard/config/ParseException.java b/app/src/main/java/com/wireguard/config/ParseException.java new file mode 100644 index 00000000..1fccb534 --- /dev/null +++ b/app/src/main/java/com/wireguard/config/ParseException.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2018 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.config; + +/** + * An exception representing a failure to parse an element of a WireGuard configuration. The context + * for this failure can be retrieved with {@link #getContext}, and the text that failed to parse can + * be retrieved with {@link #getText}. + */ +public class ParseException extends Exception { + private final String context; + private final CharSequence text; + + public ParseException(final String context, final CharSequence text, final String message) { + super(message); + this.context = context; + this.text = text; + } + + public ParseException(final String context, final CharSequence text, final Throwable cause) { + super(cause.getMessage(), cause); + this.context = context; + this.text = text; + } + + public ParseException(final String context, final CharSequence text) { + this.context = context; + this.text = text; + } + + public String getContext() { + return context; + } + + public CharSequence getText() { + return text; + } +} diff --git a/app/src/main/java/com/wireguard/config/Peer.java b/app/src/main/java/com/wireguard/config/Peer.java index 5cf0283c..50135fb0 100644 --- a/app/src/main/java/com/wireguard/config/Peer.java +++ b/app/src/main/java/com/wireguard/config/Peer.java @@ -5,363 +5,291 @@ package com.wireguard.config; -import android.annotation.SuppressLint; -import android.content.Context; -import android.databinding.BaseObservable; -import android.databinding.Bindable; -import android.os.Parcel; -import android.os.Parcelable; import android.support.annotation.Nullable; -import com.android.databinding.library.baseAdapters.BR; -import com.wireguard.android.Application; -import com.wireguard.android.R; -import com.wireguard.crypto.KeyEncoding; - -import java.net.Inet6Address; -import java.net.InetSocketAddress; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.Arrays; +import com.wireguard.crypto.Key; + import java.util.Collection; -import java.util.HashSet; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; -import java9.lang.Iterables; +import java9.util.Optional; +import java9.util.stream.Collectors; +import java9.util.stream.Stream; /** - * Represents the configuration for a WireGuard peer (a [Peer] block). + * Represents the configuration for a WireGuard peer (a [Peer] block). Peers must have a public key, + * and may optionally have several other attributes. + * <p> + * Instances of this class are immutable. */ - -public class Peer { - private final List<InetNetwork> allowedIPsList; - private final Context context = Application.get(); - @Nullable private InetEndpoint endpoint; - private int persistentKeepalive; - @Nullable private String preSharedKey; - @Nullable private String publicKey; - - public Peer() { - allowedIPsList = new ArrayList<>(); +public final class Peer { + private final Set<InetNetwork> allowedIps; + private final Optional<InetEndpoint> endpoint; + private final Optional<Integer> persistentKeepalive; + private final Optional<Key> preSharedKey; + private final Key publicKey; + + private Peer(final Builder builder) { + // Defensively copy to ensure immutability even if the Builder is reused. + allowedIps = Collections.unmodifiableSet(new LinkedHashSet<>(builder.allowedIps)); + endpoint = builder.endpoint; + persistentKeepalive = builder.persistentKeepalive; + preSharedKey = builder.preSharedKey; + publicKey = Objects.requireNonNull(builder.publicKey, "Peers must have a public key"); } - private void addAllowedIPs(@Nullable final String[] allowedIPs) { - if (allowedIPs != null && allowedIPs.length > 0) { - for (final String allowedIP : allowedIPs) { - allowedIPsList.add(new InetNetwork(allowedIP)); + /** + * Parses an series of "KEY = VALUE" lines into a {@code Peer}. Throws {@link ParseException} if + * the input is not well-formed or contains unknown attributes. + * + * @param lines an iterable sequence of lines, containing at least a public key attribute + * @return a {@code Peer} with all of its attributes set from {@code lines} + */ + public static Peer parse(final Iterable<? extends CharSequence> lines) throws ParseException { + final Builder builder = new Builder(); + for (final CharSequence line : lines) { + final Attribute attribute = Attribute.parse(line) + .orElseThrow(() -> new ParseException("[Peer]", line, "Syntax error")); + switch (attribute.getKey().toLowerCase()) { + case "allowedips": + builder.parseAllowedIPs(attribute.getValue()); + break; + case "endpoint": + builder.parseEndpoint(attribute.getValue()); + break; + case "persistentkeepalive": + builder.parsePersistentKeepalive(attribute.getValue()); + break; + case "presharedkey": + builder.parsePreSharedKey(attribute.getValue()); + break; + case "publickey": + builder.parsePublicKey(attribute.getValue()); + break; + default: + throw new ParseException("[Peer]", line, "Unknown attribute"); } } + return builder.build(); } - public InetNetwork[] getAllowedIPs() { - return allowedIPsList.toArray(new InetNetwork[allowedIPsList.size()]); + @Override + public boolean equals(final Object obj) { + if (!(obj instanceof Peer)) + return false; + final Peer other = (Peer) obj; + return allowedIps.equals(other.allowedIps) + && endpoint.equals(other.endpoint) + && persistentKeepalive.equals(other.persistentKeepalive) + && preSharedKey.equals(other.preSharedKey) + && publicKey.equals(other.publicKey); } - @Nullable - private String getAllowedIPsString() { - if (allowedIPsList.isEmpty()) - return null; - return Attribute.iterableToString(allowedIPsList); + /** + * Returns the peer's set of allowed IPs. + * + * @return the set of allowed IPs + */ + public Set<InetNetwork> getAllowedIps() { + // The collection is already immutable. + return allowedIps; } - @Nullable - public InetEndpoint getEndpoint() { + /** + * Returns the peer's endpoint. + * + * @return the endpoint, or {@code Optional.empty()} if none is configured + */ + public Optional<InetEndpoint> getEndpoint() { return endpoint; } - @Nullable - private String getEndpointString() { - if (endpoint == null) - return null; - return endpoint.getEndpoint(); - } - - public int getPersistentKeepalive() { + /** + * Returns the peer's persistent keepalive. + * + * @return the persistent keepalive, or {@code Optional.empty()} if none is configured + */ + public Optional<Integer> getPersistentKeepalive() { return persistentKeepalive; } - @Nullable - private String getPersistentKeepaliveString() { - if (persistentKeepalive == 0) - return null; - return Integer.valueOf(persistentKeepalive).toString(); - } - - @Nullable - public String getPreSharedKey() { + /** + * Returns the peer's pre-shared key. + * + * @return the pre-shared key, or {@code Optional.empty()} if none is configured + */ + public Optional<Key> getPreSharedKey() { return preSharedKey; } - @Nullable - public String getPublicKey() { + /** + * Returns the peer's public key. + * + * @return the public key + */ + public Key getPublicKey() { return publicKey; } - public String getResolvedEndpointString() throws UnknownHostException { - if (endpoint == null) - throw new UnknownHostException("{empty}"); - return endpoint.getResolvedEndpoint(); - } - - public void parse(final String line) { - final Attribute key = Attribute.match(line); - if (key == null) - throw new IllegalArgumentException(context.getString(R.string.tunnel_error_interface_parse_failed, line)); - switch (key) { - case ALLOWED_IPS: - addAllowedIPs(key.parseList(line)); - break; - case ENDPOINT: - setEndpointString(key.parse(line)); - break; - case PERSISTENT_KEEPALIVE: - setPersistentKeepaliveString(key.parse(line)); - break; - case PRESHARED_KEY: - setPreSharedKey(key.parse(line)); - break; - case PUBLIC_KEY: - setPublicKey(key.parse(line)); - break; - default: - throw new IllegalArgumentException(line); - } - } - - private void setAllowedIPsString(@Nullable final String allowedIPsString) { - allowedIPsList.clear(); - addAllowedIPs(Attribute.stringToList(allowedIPsString)); - } - - private void setEndpoint(@Nullable final InetEndpoint endpoint) { - this.endpoint = endpoint; - } - - private void setEndpointString(@Nullable final String endpoint) { - if (endpoint != null && !endpoint.isEmpty()) - setEndpoint(new InetEndpoint(endpoint)); - else - setEndpoint(null); - } - - private void setPersistentKeepalive(final int persistentKeepalive) { - this.persistentKeepalive = persistentKeepalive; - } - - private void setPersistentKeepaliveString(@Nullable final String persistentKeepalive) { - if (persistentKeepalive != null && !persistentKeepalive.isEmpty()) - setPersistentKeepalive(Integer.parseInt(persistentKeepalive, 10)); - else - setPersistentKeepalive(0); - } - - private void setPreSharedKey(@Nullable String preSharedKey) { - if (preSharedKey != null && preSharedKey.isEmpty()) - preSharedKey = null; - if (preSharedKey != null) - KeyEncoding.keyFromBase64(preSharedKey); - this.preSharedKey = preSharedKey; - } - - private void setPublicKey(@Nullable String publicKey) { - if (publicKey != null && publicKey.isEmpty()) - publicKey = null; - if (publicKey != null) - KeyEncoding.keyFromBase64(publicKey); - this.publicKey = publicKey; + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + allowedIps.hashCode(); + hash = 31 * hash + endpoint.hashCode(); + hash = 31 * hash + persistentKeepalive.hashCode(); + hash = 31 * hash + preSharedKey.hashCode(); + hash = 31 * hash + publicKey.hashCode(); + return hash; } + /** + * Converts the {@code Peer} into a string suitable for debugging purposes. The {@code Peer} is + * identified by its public key and (if known) its endpoint. + * + * @return a concise single-line identifier for the {@code Peer} + */ @Override public String toString() { - final StringBuilder sb = new StringBuilder().append("[Peer]\n"); - if (!allowedIPsList.isEmpty()) - sb.append(Attribute.ALLOWED_IPS.composeWith(allowedIPsList)); - if (endpoint != null) - sb.append(Attribute.ENDPOINT.composeWith(getEndpointString())); - if (persistentKeepalive != 0) - sb.append(Attribute.PERSISTENT_KEEPALIVE.composeWith(persistentKeepalive)); - if (preSharedKey != null) - sb.append(Attribute.PRESHARED_KEY.composeWith(preSharedKey)); - if (publicKey != null) - sb.append(Attribute.PUBLIC_KEY.composeWith(publicKey)); + final StringBuilder sb = new StringBuilder("(Peer "); + sb.append(publicKey.toBase64()); + endpoint.ifPresent(ep -> sb.append(" @").append(ep)); + sb.append(')'); return sb.toString(); } - public static class Observable extends BaseObservable implements Parcelable { - public static final Creator<Observable> CREATOR = new Creator<Observable>() { - @Override - public Observable createFromParcel(final Parcel in) { - return new Observable(in); - } - - @Override - public Observable[] newArray(final int size) { - return new Observable[size]; - } - }; - private static final List<String> DEFAULT_ROUTE_MOD_RFC1918_V4 = Arrays.asList("0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"); - private static final String DEFAULT_ROUTE_V4 = "0.0.0.0/0"; - private final List<String> interfaceDNSRoutes = new ArrayList<>(); - @Nullable private String allowedIPs; - @Nullable private String endpoint; - private int numSiblings; - @Nullable private String persistentKeepalive; - @Nullable private String preSharedKey; - @Nullable private String publicKey; - - public Observable(final Peer parent) { - loadData(parent); - } - - private Observable(final Parcel in) { - allowedIPs = in.readString(); - endpoint = in.readString(); - persistentKeepalive = in.readString(); - preSharedKey = in.readString(); - publicKey = in.readString(); - numSiblings = in.readInt(); - in.readStringList(interfaceDNSRoutes); - } - - public static Observable newInstance() { - return new Observable(new Peer()); - } - - public void commitData(final Peer parent) { - parent.setAllowedIPsString(allowedIPs); - parent.setEndpointString(endpoint); - parent.setPersistentKeepaliveString(persistentKeepalive); - parent.setPreSharedKey(preSharedKey); - parent.setPublicKey(publicKey); - if (parent.getPublicKey() == null) - throw new IllegalArgumentException(Application.get().getString(R.string.tunnel_error_empty_peer_public_key)); - loadData(parent); - notifyChange(); - } - - @Override - public int describeContents() { - return 0; - } - - @Bindable @Nullable - public String getAllowedIPs() { - return allowedIPs; - } - - @Bindable - public boolean getCanToggleExcludePrivateIPs() { - final Collection<String> ips = Arrays.asList(Attribute.stringToList(allowedIPs)); - return numSiblings == 0 && (ips.contains(DEFAULT_ROUTE_V4) || ips.containsAll(DEFAULT_ROUTE_MOD_RFC1918_V4)); - } + /** + * Converts the {@code Peer} into a string suitable for inclusion in a {@code wg-quick} + * configuration file. + * + * @return the {@code Peer} represented as a series of "Key = Value" lines + */ + public String toWgQuickString() { + final StringBuilder sb = new StringBuilder(); + if (!allowedIps.isEmpty()) + sb.append("AllowedIPs = ").append(Attribute.join(allowedIps)).append('\n'); + endpoint.ifPresent(ep -> sb.append("Endpoint = ").append(ep).append('\n')); + persistentKeepalive.ifPresent(pk -> sb.append("PersistentKeepalive = ").append(pk).append('\n')); + preSharedKey.ifPresent(psk -> sb.append("PreSharedKey = ").append(psk.toBase64()).append('\n')); + sb.append("PublicKey = ").append(publicKey.toBase64()).append('\n'); + return sb.toString(); + } - @Bindable @Nullable - public String getEndpoint() { - return endpoint; - } + /** + * Serializes the {@code Peer} for use with the WireGuard cross-platform userspace API. Note + * that not all attributes are included in this representation. + * + * @return the {@code Peer} represented as a series of "key=value" lines + */ + public String toWgUserspaceString() { + final StringBuilder sb = new StringBuilder(); + // The order here is important: public_key signifies the beginning of a new peer. + sb.append("public_key=").append(publicKey.toHex()).append('\n'); + for (final InetNetwork allowedIp : allowedIps) + sb.append("allowed_ip=").append(allowedIp).append('\n'); + endpoint.flatMap(InetEndpoint::getResolved).ifPresent(ep -> sb.append("endpoint=").append(ep).append('\n')); + persistentKeepalive.ifPresent(pk -> sb.append("persistent_keepalive_interval=").append(pk).append('\n')); + preSharedKey.ifPresent(psk -> sb.append("preshared_key=").append(psk.toHex()).append('\n')); + return sb.toString(); + } - @Bindable - public boolean getIsExcludePrivateIPsOn() { - return numSiblings == 0 && Arrays.asList(Attribute.stringToList(allowedIPs)).containsAll(DEFAULT_ROUTE_MOD_RFC1918_V4); + @SuppressWarnings("UnusedReturnValue") + public static final class Builder { + // See wg(8) + private static final int MAX_PERSISTENT_KEEPALIVE = 65535; + + // Defaults to an empty set. + private final Set<InetNetwork> allowedIps = new LinkedHashSet<>(); + // Defaults to not present. + private Optional<InetEndpoint> endpoint = Optional.empty(); + // Defaults to not present. + private Optional<Integer> persistentKeepalive = Optional.empty(); + // Defaults to not present. + private Optional<Key> preSharedKey = Optional.empty(); + // No default; must be provided before building. + @Nullable private Key publicKey; + + public Builder addAllowedIp(final InetNetwork allowedIp) { + allowedIps.add(allowedIp); + return this; } - @Bindable @Nullable - public String getPersistentKeepalive() { - return persistentKeepalive; + public Builder addAllowedIps(final Collection<InetNetwork> allowedIps) { + this.allowedIps.addAll(allowedIps); + return this; } - @Bindable @Nullable - public String getPreSharedKey() { - return preSharedKey; + public Peer build() { + return new Peer(this); } - @Bindable @Nullable - public String getPublicKey() { - return publicKey; + public Builder parseAllowedIPs(final CharSequence allowedIps) throws ParseException { + try { + final List<InetNetwork> parsed = Stream.of(Attribute.split(allowedIps)) + .map(InetNetwork::parse) + .collect(Collectors.toUnmodifiableList()); + return addAllowedIps(parsed); + } catch (final IllegalArgumentException e) { + throw new ParseException("AllowedIPs", allowedIps, e); + } } - private void loadData(final Peer parent) { - allowedIPs = parent.getAllowedIPsString(); - endpoint = parent.getEndpointString(); - persistentKeepalive = parent.getPersistentKeepaliveString(); - preSharedKey = parent.getPreSharedKey(); - publicKey = parent.getPublicKey(); + public Builder parseEndpoint(final String endpoint) throws ParseException { + try { + return setEndpoint(InetEndpoint.parse(endpoint)); + } catch (final IllegalArgumentException e) { + throw new ParseException("Endpoint", endpoint, e); + } } - public void setAllowedIPs(final String allowedIPs) { - this.allowedIPs = allowedIPs; - notifyPropertyChanged(BR.allowedIPs); - notifyPropertyChanged(BR.canToggleExcludePrivateIPs); - notifyPropertyChanged(BR.isExcludePrivateIPsOn); + public Builder parsePersistentKeepalive(final String persistentKeepalive) throws ParseException { + try { + return setPersistentKeepalive(Integer.parseInt(persistentKeepalive)); + } catch (final IllegalArgumentException e) { + throw new ParseException("PersistentKeepalive", persistentKeepalive, e); + } } - public void setEndpoint(final String endpoint) { - this.endpoint = endpoint; - notifyPropertyChanged(BR.endpoint); + public Builder parsePreSharedKey(final String preSharedKey) throws ParseException { + try { + return setPreSharedKey(Key.fromBase64(preSharedKey)); + } catch (final Key.KeyFormatException e) { + throw new ParseException("PresharedKey", preSharedKey, e); + } } - public void setInterfaceDNSRoutes(@Nullable final String dnsServers) { - final Collection<String> ips = new HashSet<>(Arrays.asList(Attribute.stringToList(allowedIPs))); - final boolean modifyAllowedIPs = ips.containsAll(DEFAULT_ROUTE_MOD_RFC1918_V4); - - ips.removeAll(interfaceDNSRoutes); - interfaceDNSRoutes.clear(); - for (final String dnsServer : Attribute.stringToList(dnsServers)) { - if (!dnsServer.contains(":")) - interfaceDNSRoutes.add(dnsServer + "/32"); + public Builder parsePublicKey(final String publicKey) throws ParseException { + try { + return setPublicKey(Key.fromBase64(publicKey)); + } catch (final Key.KeyFormatException e) { + throw new ParseException("PublicKey", publicKey, e); } - ips.addAll(interfaceDNSRoutes); - if (modifyAllowedIPs) - setAllowedIPs(Attribute.iterableToString(ips)); } - public void setNumSiblings(final int num) { - numSiblings = num; - notifyPropertyChanged(BR.canToggleExcludePrivateIPs); - notifyPropertyChanged(BR.isExcludePrivateIPsOn); + public Builder setEndpoint(final InetEndpoint endpoint) { + this.endpoint = Optional.of(endpoint); + return this; } - public void setPersistentKeepalive(final String persistentKeepalive) { - this.persistentKeepalive = persistentKeepalive; - notifyPropertyChanged(BR.persistentKeepalive); + public Builder setPersistentKeepalive(final int persistentKeepalive) { + if (persistentKeepalive < 0 || persistentKeepalive > MAX_PERSISTENT_KEEPALIVE) + throw new IllegalArgumentException("Invalid value for PersistentKeepalive"); + this.persistentKeepalive = persistentKeepalive == 0 ? + Optional.empty() : Optional.of(persistentKeepalive); + return this; } - public void setPreSharedKey(final String preSharedKey) { - this.preSharedKey = preSharedKey; - notifyPropertyChanged(BR.preSharedKey); + public Builder setPreSharedKey(final Key preSharedKey) { + this.preSharedKey = Optional.of(preSharedKey); + return this; } - public void setPublicKey(final String publicKey) { + public Builder setPublicKey(final Key publicKey) { this.publicKey = publicKey; - notifyPropertyChanged(BR.publicKey); - } - - public void toggleExcludePrivateIPs() { - final Collection<String> ips = new HashSet<>(Arrays.asList(Attribute.stringToList(allowedIPs))); - final boolean hasDefaultRoute = ips.contains(DEFAULT_ROUTE_V4); - final boolean hasDefaultRouteModRFC1918 = ips.containsAll(DEFAULT_ROUTE_MOD_RFC1918_V4); - if ((!hasDefaultRoute && !hasDefaultRouteModRFC1918) || numSiblings > 0) - return; - Iterables.removeIf(ips, ip -> !ip.contains(":")); - if (hasDefaultRoute) { - ips.addAll(DEFAULT_ROUTE_MOD_RFC1918_V4); - ips.addAll(interfaceDNSRoutes); - } else if (hasDefaultRouteModRFC1918) - ips.add(DEFAULT_ROUTE_V4); - setAllowedIPs(Attribute.iterableToString(ips)); - } - - @Override - public void writeToParcel(final Parcel dest, final int flags) { - dest.writeString(allowedIPs); - dest.writeString(endpoint); - dest.writeString(persistentKeepalive); - dest.writeString(preSharedKey); - dest.writeString(publicKey); - dest.writeInt(numSiblings); - dest.writeStringList(interfaceDNSRoutes); + return this; } } } |