Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a13007ad authored by Remi NGUYEN VAN's avatar Remi NGUYEN VAN
Browse files

Add DhcpServer

This first version can serve discover/request/release, although there
are some small behavior changes with current implementation which will
be addressed later.

Also removes final modifiers on start() and stop() in FdEventsReader, to
allow mocking the methods in tests with the current mockito lib.

Test: Added tests pass, manual: flashed a device using the server
Change-Id: I025366ff7d51c4ba31152af50f3dd2b5e280a54d
parent a7587203
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -139,6 +139,8 @@ public class TrafficStats {
    public static final int TAG_SYSTEM_GPS = 0xFFFFFF44;
    /** @hide */
    public static final int TAG_SYSTEM_PAC = 0xFFFFFF45;
    /** @hide */
    public static final int TAG_SYSTEM_DHCP_SERVER = 0xFFFFFF46;

    private static INetworkStatsService sStatsService;

+1 −10
Original line number Diff line number Diff line
@@ -29,7 +29,7 @@ import android.annotation.Nullable;
import android.net.IpPrefix;
import android.net.MacAddress;
import android.net.util.SharedLog;
import android.os.SystemClock;
import android.net.dhcp.DhcpServer.Clock;
import android.util.ArrayMap;

import java.net.Inet4Address;
@@ -73,15 +73,6 @@ class DhcpLeaseRepository {
    private int mNumAddresses;
    private long mLeaseTimeMs;

    public static class Clock {
        /**
         * @see SystemClock#elapsedRealtime()
         */
        public long elapsedRealtime() {
            return SystemClock.elapsedRealtime();
        }
    }

    /**
     * Next timestamp when committed or declined leases should be checked for expired ones. This
     * will always be lower than or equal to the time for the first lease to expire: it's OK not to
+9 −0
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ import android.os.Build;
import android.os.SystemProperties;
import android.system.OsConstants;
import android.text.TextUtils;

import com.android.internal.annotations.VisibleForTesting;

import java.io.UnsupportedEncodingException;
@@ -350,6 +351,14 @@ public abstract class DhcpPacket {
        return mClientId != null;
    }

    /**
     * Convenience method to return the client ID if it was set explicitly, or null otherwise.
     */
    @Nullable
    public byte[] getExplicitClientIdOrNull() {
        return hasExplicitClientId() ? getClientId() : null;
    }

    /**
     * Returns the client ID. If not set explicitly, this follows RFC 2132 and creates a client ID
     * based on the hardware address.
+511 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.net.dhcp;

import static android.net.NetworkUtils.getBroadcastAddress;
import static android.net.NetworkUtils.getPrefixMaskAsInet4Address;
import static android.net.TrafficStats.TAG_SYSTEM_DHCP_SERVER;
import static android.net.dhcp.DhcpPacket.DHCP_SERVER;
import static android.net.dhcp.DhcpPacket.ENCAP_BOOTP;
import static android.net.dhcp.DhcpPacket.INFINITE_LEASE;
import static android.system.OsConstants.AF_INET;
import static android.system.OsConstants.IPPROTO_UDP;
import static android.system.OsConstants.SOCK_DGRAM;
import static android.system.OsConstants.SOL_SOCKET;
import static android.system.OsConstants.SO_BINDTODEVICE;
import static android.system.OsConstants.SO_BROADCAST;
import static android.system.OsConstants.SO_REUSEADDR;

import static java.lang.Integer.toUnsignedLong;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.net.MacAddress;
import android.net.NetworkUtils;
import android.net.TrafficStats;
import android.net.util.InterfaceParams;
import android.net.util.SharedLog;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.system.ErrnoException;
import android.system.Os;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.HexDump;

import java.io.FileDescriptor;
import java.io.IOException;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.ArrayList;

/**
 * A DHCPv4 server.
 *
 * <p>This server listens for and responds to packets on a single interface. It considers itself
 * authoritative for all leases on the subnet, which means that DHCP requests for unknown leases of
 * unknown hosts receive a reply instead of being ignored.
 *
 * <p>The server is single-threaded (including send/receive operations): all internal operations are
 * done on the provided {@link Looper}. Public methods are thread-safe and will schedule operations
 * on the looper asynchronously.
 * @hide
 */
public class DhcpServer {
    private static final String REPO_TAG = "Repository";

    // Lease time to transmit to client instead of a negative time in case a lease expired before
    // the server could send it (if the server process is suspended for example).
    private static final int EXPIRED_FALLBACK_LEASE_TIME_SECS = 120;

    private static final int CMD_START_DHCP_SERVER = 1;
    private static final int CMD_STOP_DHCP_SERVER = 2;
    private static final int CMD_UPDATE_PARAMS = 3;

    @NonNull
    private final ServerHandler mHandler;
    @NonNull
    private final InterfaceParams mIface;
    @NonNull
    private final DhcpLeaseRepository mLeaseRepo;
    @NonNull
    private final SharedLog mLog;
    @NonNull
    private final Dependencies mDeps;
    @NonNull
    private final Clock mClock;
    @NonNull
    private final DhcpPacketListener mPacketListener;

    @Nullable
    private FileDescriptor mSocket;
    @NonNull
    private DhcpServingParams mServingParams;

    public static class Clock {
        /**
         * @see SystemClock#elapsedRealtime()
         */
        public long elapsedRealtime() {
            return SystemClock.elapsedRealtime();
        }
    }

    public interface Dependencies {
        void sendPacket(@NonNull FileDescriptor fd, @NonNull ByteBuffer buffer,
                @NonNull InetAddress dst) throws ErrnoException, IOException;
        DhcpLeaseRepository makeLeaseRepository(@NonNull DhcpServingParams servingParams,
                @NonNull SharedLog log, @NonNull Clock clock);
        DhcpPacketListener makePacketListener();
        Clock makeClock();
        void addArpEntry(@NonNull Inet4Address ipv4Addr, @NonNull MacAddress ethAddr,
                @NonNull String ifname, @NonNull FileDescriptor fd) throws IOException;
    }

    private class DependenciesImpl implements Dependencies {
        @Override
        public void sendPacket(@NonNull FileDescriptor fd, @NonNull ByteBuffer buffer,
                @NonNull InetAddress dst) throws ErrnoException, IOException {
            Os.sendto(fd, buffer, 0, dst, DhcpPacket.DHCP_CLIENT);
        }

        @Override
        public DhcpLeaseRepository makeLeaseRepository(@NonNull DhcpServingParams servingParams,
                @NonNull SharedLog log, @NonNull Clock clock) {
            return new DhcpLeaseRepository(
                    DhcpServingParams.makeIpPrefix(servingParams.serverAddr),
                    servingParams.excludedAddrs,
                    servingParams.dhcpLeaseTimeSecs*1000, log.forSubComponent(REPO_TAG), clock);
        }

        @Override
        public DhcpPacketListener makePacketListener() {
            return new PacketListener();
        }

        @Override
        public Clock makeClock() {
            return new Clock();
        }

        @Override
        public void addArpEntry(@NonNull Inet4Address ipv4Addr, @NonNull MacAddress ethAddr,
                @NonNull String ifname, @NonNull FileDescriptor fd) throws IOException {
            NetworkUtils.addArpEntry(ipv4Addr, ethAddr, ifname, fd);
        }
    }

    private static class MalformedPacketException extends Exception {
        MalformedPacketException(String message, Throwable t) {
            super(message, t);
        }
    }

    public DhcpServer(@NonNull Looper looper, @NonNull InterfaceParams iface,
            @NonNull DhcpServingParams params, @NonNull SharedLog log) {
        this(looper, iface, params, log, null);
    }

    @VisibleForTesting
    DhcpServer(@NonNull Looper looper, @NonNull InterfaceParams iface,
            @NonNull DhcpServingParams params, @NonNull SharedLog log,
            @Nullable Dependencies deps) {
        if (deps == null) {
            deps = new DependenciesImpl();
        }
        mHandler = new ServerHandler(looper);
        mIface = iface;
        mServingParams = params;
        mLog = log;
        mDeps = deps;
        mClock = deps.makeClock();
        mPacketListener = deps.makePacketListener();
        mLeaseRepo = deps.makeLeaseRepository(mServingParams, mLog, mClock);
    }

    /**
     * Start listening for and responding to packets.
     */
    public void start() {
        mHandler.sendEmptyMessage(CMD_START_DHCP_SERVER);
    }

    /**
     * Update serving parameters. All subsequently received requests will be handled with the new
     * parameters, and current leases that are incompatible with the new parameters are dropped.
     */
    public void updateParams(@NonNull DhcpServingParams params) {
        sendMessage(CMD_UPDATE_PARAMS, params);
    }

    /**
     * Stop listening for packets.
     *
     * <p>As the server is stopped asynchronously, some packets may still be processed shortly after
     * calling this method.
     */
    public void stop() {
        mHandler.sendEmptyMessage(CMD_STOP_DHCP_SERVER);
    }

    private void sendMessage(int what, @Nullable Object obj) {
        mHandler.sendMessage(mHandler.obtainMessage(what, obj));
    }

    private class ServerHandler extends Handler {
        public ServerHandler(@NonNull Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(@NonNull Message msg) {
            switch (msg.what) {
                case CMD_UPDATE_PARAMS:
                    final DhcpServingParams params = (DhcpServingParams) msg.obj;
                    mServingParams = params;
                    mLeaseRepo.updateParams(
                            DhcpServingParams.makeIpPrefix(mServingParams.serverAddr),
                            params.excludedAddrs,
                            params.dhcpLeaseTimeSecs);
                    break;
                case CMD_START_DHCP_SERVER:
                    // This is a no-op if the listener is already started
                    mPacketListener.start();
                    break;
                case CMD_STOP_DHCP_SERVER:
                    // This is a no-op if the listener was not started
                    mPacketListener.stop();
                    break;
            }
        }
    }

    @VisibleForTesting
    void processPacket(@NonNull DhcpPacket packet) {
        mLog.log("Received packet of type " + packet.getClass().getSimpleName());
        final Inet4Address sid = packet.mServerIdentifier;
        if (sid != null && !sid.equals(mServingParams.serverAddr.getAddress())) {
            mLog.log("Packet ignored due to wrong server identifier: " + sid);
            return;
        }

        try {
            if (packet instanceof DhcpDiscoverPacket) {
                processDiscover((DhcpDiscoverPacket) packet);
            } else if (packet instanceof DhcpRequestPacket) {
                processRequest((DhcpRequestPacket) packet);
            } else if (packet instanceof DhcpReleasePacket) {
                processRelease((DhcpReleasePacket) packet);
            } else {
                mLog.e("Unknown packet type: " + packet.getClass().getSimpleName());
            }
        } catch (MalformedPacketException e) {
            // Not an internal error: only logging exception message, not stacktrace
            mLog.e("Ignored malformed packet: " + e.getMessage());
        }
    }

    private void processDiscover(@NonNull DhcpDiscoverPacket packet)
            throws MalformedPacketException {
        final DhcpLease lease;
        final MacAddress clientMac = getMacAddr(packet);
        try {
            lease = mLeaseRepo.getOffer(packet.getExplicitClientIdOrNull(), clientMac,
                    packet.mRelayIp, packet.mRequestedIp, packet.mHostName);
        } catch (DhcpLeaseRepository.OutOfAddressesException e) {
            transmitNak(packet, "Out of addresses to offer");
            return;
        } catch (DhcpLeaseRepository.InvalidAddressException e) {
            transmitNak(packet, "Lease requested from an invalid subnet");
            return;
        }

        transmitOffer(packet, lease, clientMac);
    }

    private void processRequest(@NonNull DhcpRequestPacket packet) throws MalformedPacketException {
        // If set, packet SID matches with this server's ID as checked in processPacket().
        final boolean sidSet = packet.mServerIdentifier != null;
        final DhcpLease lease;
        final MacAddress clientMac = getMacAddr(packet);
        try {
            lease = mLeaseRepo.requestLease(packet.getExplicitClientIdOrNull(), clientMac,
                    packet.mClientIp, packet.mRequestedIp, sidSet, packet.mHostName);
        } catch (DhcpLeaseRepository.InvalidAddressException e) {
            transmitNak(packet, "Invalid requested address");
            return;
        }

        transmitAck(packet, lease, clientMac);
    }

    private void processRelease(@Nullable DhcpReleasePacket packet)
            throws MalformedPacketException {
        final byte[] clientId = packet.getExplicitClientIdOrNull();
        final MacAddress macAddr = getMacAddr(packet);
        // Don't care about success (there is no ACK/NAK); logging is already done in the repository
        mLeaseRepo.releaseLease(clientId, macAddr, packet.mClientIp);
    }

    private Inet4Address getAckOrOfferDst(@NonNull DhcpPacket request, @NonNull DhcpLease lease,
            boolean broadcastFlag) {
        // Unless relayed or broadcast, send to client IP if already configured on the client, or to
        // the lease address if the client has no configured address
        if (!isEmpty(request.mRelayIp)) {
            return request.mRelayIp;
        } else if (broadcastFlag) {
            return (Inet4Address) Inet4Address.ALL;
        } else if (!isEmpty(request.mClientIp)) {
            return request.mClientIp;
        } else {
            return lease.getNetAddr();
        }
    }

    /**
     * Determine whether the broadcast flag should be set in the BOOTP packet flags. This does not
     * apply to NAK responses, which should always have it set.
     */
    private static boolean getBroadcastFlag(@NonNull DhcpPacket request, @NonNull DhcpLease lease) {
        // No broadcast flag if the client already has a configured IP to unicast to. RFC2131 #4.1
        // has some contradictions regarding broadcast behavior if a client already has an IP
        // configured and sends a request with both ciaddr (renew/rebind) and the broadcast flag
        // set. Sending a unicast response to ciaddr matches previous behavior and is more
        // efficient.
        // If the client has no configured IP, broadcast if requested by the client or if the lease
        // address cannot be used to send a unicast reply either.
        return isEmpty(request.mClientIp) && (request.mBroadcast || isEmpty(lease.getNetAddr()));
    }

    private boolean transmitOffer(@NonNull DhcpPacket request, @NonNull DhcpLease lease,
            @NonNull MacAddress clientMac) {
        final boolean broadcastFlag = getBroadcastFlag(request, lease);
        final int timeout = getLeaseTimeout(lease);
        final Inet4Address prefixMask =
                getPrefixMaskAsInet4Address(mServingParams.serverAddr.getPrefixLength());
        final Inet4Address broadcastAddr = getBroadcastAddress(
                mServingParams.getServerInet4Addr(), mServingParams.serverAddr.getPrefixLength());
        final ByteBuffer offerPacket = DhcpPacket.buildOfferPacket(
                ENCAP_BOOTP, request.mTransId, broadcastFlag, mServingParams.getServerInet4Addr(),
                lease.getNetAddr(), request.mClientMac, timeout,
                prefixMask,
                broadcastAddr,
                new ArrayList<>(mServingParams.defaultRouters),
                new ArrayList<>(mServingParams.dnsServers),
                mServingParams.getServerInet4Addr(), null /* domainName */);

        return transmitOfferOrAckPacket(offerPacket, request, lease, clientMac, broadcastFlag);
    }

    private boolean transmitAck(@NonNull DhcpPacket request, @NonNull DhcpLease lease,
            @NonNull MacAddress clientMac) {
        // TODO: replace DhcpPacket's build methods with real builders and use common code with
        // transmitOffer above
        final boolean broadcastFlag = getBroadcastFlag(request, lease);
        final int timeout = getLeaseTimeout(lease);
        final ByteBuffer ackPacket = DhcpPacket.buildAckPacket(ENCAP_BOOTP, request.mTransId,
                broadcastFlag, mServingParams.getServerInet4Addr(), lease.getNetAddr(),
                request.mClientMac, timeout, mServingParams.getPrefixMaskAsAddress(),
                mServingParams.getBroadcastAddress(),
                new ArrayList<>(mServingParams.defaultRouters),
                new ArrayList<>(mServingParams.dnsServers),
                mServingParams.getServerInet4Addr(), null /* domainName */);

        return transmitOfferOrAckPacket(ackPacket, request, lease, clientMac, broadcastFlag);
    }

    private boolean transmitNak(DhcpPacket request, String message) {
        mLog.w("Transmitting NAK: " + message);
        // Always set broadcast flag for NAK: client may not have a correct IP
        final ByteBuffer nakPacket = DhcpPacket.buildNakPacket(
                ENCAP_BOOTP, request.mTransId, mServingParams.getServerInet4Addr(),
                request.mClientMac, true /* broadcast */, message);

        final Inet4Address dst = isEmpty(request.mRelayIp)
                ? (Inet4Address) Inet4Address.ALL
                : request.mRelayIp;
        return transmitPacket(nakPacket, DhcpNakPacket.class.getSimpleName(), dst);
    }

    private boolean transmitOfferOrAckPacket(@NonNull ByteBuffer buf, @NonNull DhcpPacket request,
            @NonNull DhcpLease lease, @NonNull MacAddress clientMac, boolean broadcastFlag) {
        mLog.logf("Transmitting %s with lease %s", request.getClass().getSimpleName(), lease);
        // Client may not yet respond to ARP for the lease address, which may be the destination
        // address. Add an entry to the ARP cache to save future ARP probes and make sure the
        // packet reaches its destination.
        if (!addArpEntry(clientMac, lease.getNetAddr())) {
            // Logging for error already done
            return false;
        }
        final Inet4Address dst = getAckOrOfferDst(request, lease, broadcastFlag);
        return transmitPacket(buf, request.getClass().getSimpleName(), dst);
    }

    private boolean transmitPacket(@NonNull ByteBuffer buf, @NonNull String packetTypeTag,
            @NonNull Inet4Address dst) {
        try {
            mDeps.sendPacket(mSocket, buf, dst);
        } catch (ErrnoException | IOException e) {
            mLog.e("Can't send packet " + packetTypeTag, e);
            return false;
        }
        return true;
    }

    private boolean addArpEntry(@NonNull MacAddress macAddr, @NonNull Inet4Address inetAddr) {
        try {
            mDeps.addArpEntry(inetAddr, macAddr, mIface.name, mSocket);
            return true;
        } catch (IOException e) {
            mLog.e("Error adding client to ARP table", e);
            return false;
        }
    }

    /**
     * Get the remaining lease time in seconds, starting from {@link Clock#elapsedRealtime()}.
     *
     * <p>This is an unsigned 32-bit integer, so it cannot be read as a standard (signed) Java int.
     * The return value is only intended to be used to populate the lease time field in a DHCP
     * response, considering that lease time is an unsigned 32-bit integer field in DHCP packets.
     *
     * <p>Lease expiration times are tracked internally with millisecond precision: this method
     * returns a rounded down value.
     */
    private int getLeaseTimeout(@NonNull DhcpLease lease) {
        final long remainingTimeSecs = (lease.getExpTime() - mClock.elapsedRealtime()) / 1000;
        if (remainingTimeSecs < 0) {
            mLog.e("Processing expired lease " + lease);
            return EXPIRED_FALLBACK_LEASE_TIME_SECS;
        }

        if (remainingTimeSecs >= toUnsignedLong(INFINITE_LEASE)) {
            return INFINITE_LEASE;
        }

        return (int) remainingTimeSecs;
    }

    /**
     * Get the client MAC address from a packet.
     *
     * @throws MalformedPacketException The address in the packet uses an unsupported format.
     */
    @NonNull
    private MacAddress getMacAddr(@NonNull DhcpPacket packet) throws MalformedPacketException {
        try {
            return MacAddress.fromBytes(packet.getClientMac());
        } catch (IllegalArgumentException e) {
            final String message = "Invalid MAC address in packet: "
                    + HexDump.dumpHexString(packet.getClientMac());
            throw new MalformedPacketException(message, e);
        }
    }

    private static boolean isEmpty(@NonNull Inet4Address address) {
        return address == null || Inet4Address.ANY.equals(address);
    }

    private class PacketListener extends DhcpPacketListener {
        public PacketListener() {
            super(mHandler);
        }

        @Override
        protected void onReceive(DhcpPacket packet, Inet4Address srcAddr) {
            processPacket(packet);
        }

        @Override
        protected void logError(String msg, Exception e) {
            mLog.e("Error receiving packet: " + msg, e);
        }

        @Override
        protected void logParseError(byte[] packet, int length, DhcpPacket.ParseException e) {
            mLog.e("Error parsing packet", e);
        }

        @Override
        protected FileDescriptor createFd() {
            // TODO: have and use an API to set a socket tag without going through the thread tag
            final int oldTag = TrafficStats.getAndSetThreadStatsTag(TAG_SYSTEM_DHCP_SERVER);
            try {
                mSocket = Os.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
                Os.setsockoptInt(mSocket, SOL_SOCKET, SO_REUSEADDR, 1);
                // SO_BINDTODEVICE actually takes a string. This works because the first member
                // of struct ifreq is a NULL-terminated interface name.
                // TODO: add a setsockoptString()
                Os.setsockoptIfreq(mSocket, SOL_SOCKET, SO_BINDTODEVICE, mIface.name);
                Os.setsockoptInt(mSocket, SOL_SOCKET, SO_BROADCAST, 1);
                Os.bind(mSocket, Inet4Address.ANY, DHCP_SERVER);
                NetworkUtils.protectFromVpn(mSocket);

                return mSocket;
            } catch (IOException | ErrnoException e) {
                mLog.e("Error creating UDP socket", e);
                DhcpServer.this.stop();
                return null;
            } finally {
                TrafficStats.setThreadStatsTag(oldTag);
            }
        }
    }
}
+2 −2
Original line number Diff line number Diff line
@@ -89,7 +89,7 @@ public abstract class FdEventsReader<BufferType> {
        mBuffer = buffer;
    }

    public final void start() {
    public void start() {
        if (onCorrectThread()) {
            createAndRegisterFd();
        } else {
@@ -100,7 +100,7 @@ public abstract class FdEventsReader<BufferType> {
        }
    }

    public final void stop() {
    public void stop() {
        if (onCorrectThread()) {
            unregisterAndDestroyFd();
        } else {
Loading