001/*
002 * Copyright (C) Photon Vision.
003 *
004 * This program is free software: you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License as published by
006 * the Free Software Foundation, either version 3 of the License, or
007 * (at your option) any later version.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
016 */
017
018package org.photonvision.common.networking;
019
020import java.net.NetworkInterface;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.nio.file.Paths;
024import java.util.HashMap;
025import java.util.NoSuchElementException;
026import org.photonvision.common.configuration.ConfigManager;
027import org.photonvision.common.configuration.NetworkConfig;
028import org.photonvision.common.dataflow.DataChangeDestination;
029import org.photonvision.common.dataflow.DataChangeService;
030import org.photonvision.common.dataflow.DataChangeSource;
031import org.photonvision.common.dataflow.events.DataChangeEvent;
032import org.photonvision.common.hardware.Platform;
033import org.photonvision.common.hardware.PlatformUtils;
034import org.photonvision.common.logging.LogGroup;
035import org.photonvision.common.logging.Logger;
036import org.photonvision.common.networking.NetworkUtils.NMDeviceInfo;
037import org.photonvision.common.util.ShellExec;
038import org.photonvision.common.util.TimedTaskManager;
039
040public class NetworkManager {
041    private static final Logger logger = new Logger(NetworkManager.class, LogGroup.General);
042    private HashMap<String, String> activeConnections = new HashMap<String, String>();
043
044    private NetworkManager() {}
045
046    private static class SingletonHolder {
047        private static final NetworkManager INSTANCE = new NetworkManager();
048    }
049
050    public static NetworkManager getInstance() {
051        return SingletonHolder.INSTANCE;
052    }
053
054    private boolean isManaged = false;
055    public boolean networkingIsDisabled = false; // Passed in via CLI
056
057    public void initialize(boolean shouldManage) {
058        isManaged = shouldManage && !networkingIsDisabled;
059        if (!isManaged) {
060            logger.info("Network management is disabled.");
061            return;
062        }
063
064        if (!Platform.isLinux()) {
065            logger.info("Not managing network on non-Linux platforms.");
066            this.networkingIsDisabled = true;
067            return;
068        }
069
070        if (!PlatformUtils.isRoot()) {
071            logger.error("Cannot manage network without root!");
072            this.networkingIsDisabled = true;
073            return;
074        }
075
076        // Start tasks to monitor the network interface(s)
077        var ethernetDevices = NetworkUtils.getAllWiredInterfaces();
078        for (NMDeviceInfo deviceInfo : ethernetDevices) {
079            activeConnections.put(
080                    deviceInfo.devName, NetworkUtils.getActiveConnection(deviceInfo.devName));
081            monitorDevice(deviceInfo.devName, 5000);
082        }
083
084        var physicalDevices = NetworkUtils.getAllActiveWiredInterfaces();
085        var config = ConfigManager.getInstance().getConfig().getNetworkConfig();
086        if (physicalDevices.stream().noneMatch(it -> (it.devName.equals(config.networkManagerIface)))) {
087            try {
088                // if the configured interface isn't in the list of available ones, select one that is
089                var iFace = physicalDevices.stream().findFirst().orElseThrow();
090                logger.warn(
091                        "The configured interface doesn't match any available interface. Applying configuration to "
092                                + iFace.devName);
093                // update NetworkConfig with found interface
094                config.networkManagerIface = iFace.devName;
095                ConfigManager.getInstance().requestSave();
096            } catch (NoSuchElementException e) {
097                // if there are no available interfaces, go with the one from settings
098                logger.warn("No physical interface found. Maybe ethernet isn't connected?");
099                if (config.networkManagerIface == null || config.networkManagerIface.isBlank()) {
100                    // if it's also empty, there is nothing to configure
101                    logger.error("No valid network interfaces to manage");
102                    return;
103                }
104            }
105        }
106
107        logger.info(
108                "Setting "
109                        + config.connectionType
110                        + " with team "
111                        + config.ntServerAddress
112                        + " on "
113                        + config.networkManagerIface);
114
115        // always set hostname (unless it's blank)
116        if (!config.hostname.isBlank()) {
117            setHostname(config.hostname);
118        } else {
119            logger.warn("Got empty hostname?");
120        }
121
122        if (config.connectionType == NetworkMode.DHCP) {
123            setConnectionDHCP(config);
124        } else if (config.connectionType == NetworkMode.STATIC) {
125            setConnectionStatic(config);
126        }
127    }
128
129    public void reinitialize() {
130        initialize(ConfigManager.getInstance().getConfig().getNetworkConfig().shouldManage);
131
132        DataChangeService.getInstance()
133                .publishEvent(
134                        new DataChangeEvent<Boolean>(
135                                DataChangeSource.DCS_OTHER,
136                                DataChangeDestination.DCD_WEBSERVER,
137                                "restartServer",
138                                true));
139    }
140
141    private void setHostname(String hostname) {
142        try {
143            var shell = new ShellExec(true, false);
144            shell.executeBashCommand("cat /etc/hostname | tr -d \" \\t\\n\\r\"");
145            var oldHostname = shell.getOutput().replace("\n", "");
146            logger.debug("Old host name: \"" + oldHostname + "\"");
147            logger.debug("New host name: \"" + hostname + "\"");
148
149            if (!oldHostname.equals(hostname)) {
150                var setHostnameRetCode =
151                        shell.executeBashCommand(
152                                "echo $NEW_HOSTNAME > /etc/hostname".replace("$NEW_HOSTNAME", hostname));
153                setHostnameRetCode = shell.executeBashCommand("hostnamectl set-hostname " + hostname);
154
155                // Add to /etc/hosts
156                var addHostRetCode =
157                        shell.executeBashCommand(
158                                String.format(
159                                        "sed -i \"s/127.0.1.1.*%s/127.0.1.1\\t%s/g\" /etc/hosts",
160                                        oldHostname, hostname));
161
162                shell.executeBashCommand("systemctl restart avahi-daemon.service");
163
164                var success = setHostnameRetCode == 0 && addHostRetCode == 0;
165                if (!success) {
166                    logger.error(
167                            "Setting hostname returned non-zero codes (hostname/hosts) "
168                                    + setHostnameRetCode
169                                    + "|"
170                                    + addHostRetCode
171                                    + "!");
172                } else {
173                    logger.info("Set hostname to " + hostname);
174                }
175            }
176        } catch (Exception e) {
177            logger.error("Failed to set hostname!", e);
178        }
179    }
180
181    private void setConnectionDHCP(NetworkConfig config) {
182        String connName = "dhcp-" + config.networkManagerIface;
183
184        var shell = new ShellExec();
185        try {
186            if (NetworkUtils.connDoesNotExist(connName)) {
187                logger.info("Creating DHCP connection " + connName);
188                shell.executeBashCommand(
189                        NetworkingCommands.addConnectionCommand
190                                .replace("${connection}", connName)
191                                .replace("${interface}", config.networkManagerIface));
192            }
193            logger.info("Updating the DHCP connection " + connName);
194            shell.executeBashCommand(
195                    NetworkingCommands.modDHCPCommand.replace("${connection}", connName));
196            // activate it
197            logger.info("Activating DHCP connection " + connName);
198            shell.executeBashCommand(
199                    "nmcli connection up \"${connection}\"".replace("${connection}", connName), false);
200            activeConnections.put(config.networkManagerIface, connName);
201        } catch (Exception e) {
202            logger.error("Exception while setting DHCP!", e);
203        }
204    }
205
206    private void setConnectionStatic(NetworkConfig config) {
207        String connName = "static-" + config.networkManagerIface;
208
209        if (config.staticIp.isBlank()) {
210            logger.warn("Got empty static IP?");
211            return;
212        }
213
214        // guess at the gateway from the staticIp
215        String[] parts = config.staticIp.split("\\.");
216        parts[parts.length - 1] = "1";
217        String gateway = String.join(".", parts);
218
219        var shell = new ShellExec();
220        try {
221            if (NetworkUtils.connDoesNotExist(connName)) {
222                // create connection
223                logger.info("Creating Static connection " + connName);
224                shell.executeBashCommand(
225                        NetworkingCommands.addConnectionCommand
226                                .replace("${connection}", connName)
227                                .replace("${interface}", config.networkManagerIface));
228            }
229            // modify it in case the static IP address is different
230            logger.info("Updating the Static connection " + connName);
231            shell.executeBashCommand(
232                    NetworkingCommands.modStaticCommand
233                            .replace("${connection}", connName)
234                            .replace("${ipaddr}", config.staticIp)
235                            .replace("${gateway}", gateway));
236            // activate it
237            logger.info("Activating the Static connection " + connName);
238            shell.executeBashCommand(
239                    "nmcli connection up \"${connection}\"".replace("${connection}", connName), false);
240            activeConnections.put(config.networkManagerIface, connName);
241        } catch (Exception e) {
242            logger.error("Error while setting static IP!", e);
243        }
244    }
245
246    // Detects changes in the carrier and reinitializes after re-connect
247    private void monitorDevice(String devName, int millisInterval) {
248        String taskName = "deviceStatus-" + devName;
249        if (TimedTaskManager.getInstance().taskActive(taskName)) {
250            // task is already running
251            return;
252        }
253        Path path = Paths.get("/sys/class/net/{device}/carrier".replace("{device}", devName));
254        if (Files.notExists(path)) {
255            logger.error("Can't find " + path + ", so can't monitor " + devName);
256            return;
257        }
258        var last =
259                new Object() {
260                    boolean carrier = true;
261                    boolean exceptionLogged = false;
262                    String addresses = "";
263                };
264        Runnable task =
265                () -> {
266                    try {
267                        boolean carrier = Files.readString(path).trim().equals("1");
268                        if (carrier != last.carrier) {
269                            if (carrier) {
270                                // carrier came back
271                                logger.info("Interface " + devName + " has re-connected, reinitializing");
272                                reinitialize();
273                            } else {
274                                logger.warn("Interface " + devName + " is disconnected, check Ethernet!");
275                            }
276                        }
277                        var iFace = NetworkInterface.getByName(devName);
278                        if (iFace != null && iFace.isUp()) {
279                            String tmpAddresses = "";
280                            tmpAddresses = iFace.getInterfaceAddresses().toString();
281                            if (!last.addresses.equals(tmpAddresses)) {
282                                // addresses have changed, log the difference
283                                last.addresses = tmpAddresses;
284                                logger.info("Interface " + devName + " has address(es): " + last.addresses);
285                            }
286                            var conn = NetworkUtils.getActiveConnection(devName);
287                            if (!conn.equals(activeConnections.get(devName))) {
288                                logger.warn(
289                                        "Unexpected connection "
290                                                + conn
291                                                + " active on "
292                                                + devName
293                                                + ". Expected "
294                                                + activeConnections.get(devName));
295                                logger.info("Reinitializing");
296                                reinitialize();
297                            }
298                        }
299                        last.carrier = carrier;
300                        last.exceptionLogged = false;
301                    } catch (Exception e) {
302                        if (!last.exceptionLogged) {
303                            // Log the exception only once, but keep trying
304                            logger.error("Could not check network status for " + devName, e);
305                            last.exceptionLogged = true;
306                        }
307                    }
308                };
309
310        TimedTaskManager.getInstance().addTask(taskName, task, millisInterval);
311        logger.debug("Watching network interface at path: " + path);
312    }
313}