001package voyeur;
002/*-
003 * ##########################################################################
004 * Local Area Network Voyeur
005 * %%
006 * Copyright (C) 2019 - 2022 Allen D. Ball
007 * %%
008 * Licensed under the Apache License, Version 2.0 (the "License");
009 * you may not use this file except in compliance with the License.
010 * You may obtain a copy of the License at
011 *
012 *      http://www.apache.org/licenses/LICENSE-2.0
013 *
014 * Unless required by applicable law or agreed to in writing, software
015 * distributed under the License is distributed on an "AS IS" BASIS,
016 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
017 * See the License for the specific language governing permissions and
018 * limitations under the License.
019 * ##########################################################################
020 */
021import ball.upnp.ssdp.SSDPResponse;
022import ball.xml.XalanConstants;
023import java.io.BufferedReader;
024import java.io.ByteArrayOutputStream;
025import java.io.IOException;
026import java.io.InputStreamReader;
027import java.net.Inet4Address;
028import java.net.Inet6Address;
029import java.net.InetAddress;
030import java.net.InetSocketAddress;
031import java.net.InterfaceAddress;
032import java.net.NetworkInterface;
033import java.net.SocketAddress;
034import java.time.Duration;
035import java.time.Instant;
036import java.util.LinkedHashSet;
037import java.util.List;
038import java.util.Set;
039import java.util.TreeSet;
040import java.util.stream.Stream;
041import javax.annotation.PostConstruct;
042import javax.annotation.PreDestroy;
043import javax.xml.namespace.QName;
044import javax.xml.parsers.DocumentBuilderFactory;
045import javax.xml.transform.Transformer;
046import javax.xml.transform.TransformerFactory;
047import javax.xml.transform.dom.DOMSource;
048import javax.xml.transform.stream.StreamResult;
049import javax.xml.xpath.XPath;
050import javax.xml.xpath.XPathFactory;
051import lombok.EqualsAndHashCode;
052import lombok.NoArgsConstructor;
053import lombok.RequiredArgsConstructor;
054import lombok.ToString;
055import lombok.extern.log4j.Log4j2;
056import org.springframework.beans.factory.annotation.Autowired;
057import org.springframework.boot.context.event.ApplicationReadyEvent;
058import org.springframework.context.event.EventListener;
059import org.springframework.http.MediaType;
060import org.springframework.scheduling.annotation.Scheduled;
061import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
062import org.springframework.stereotype.Service;
063import org.springframework.web.bind.annotation.PathVariable;
064import org.springframework.web.bind.annotation.RequestMapping;
065import org.springframework.web.bind.annotation.RestController;
066import org.w3c.dom.Document;
067import org.w3c.dom.NodeList;
068
069import static java.lang.ProcessBuilder.Redirect.PIPE;
070import static java.nio.charset.StandardCharsets.UTF_8;
071import static java.util.stream.Collectors.toList;
072import static javax.xml.transform.OutputKeys.INDENT;
073import static javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION;
074import static javax.xml.xpath.XPathConstants.NODESET;
075import static javax.xml.xpath.XPathConstants.NUMBER;
076
077/**
078 * {@link InetAddress} to {@code nmap} output {@link Document}
079 * {@link java.util.Map}.
080 *
081 * {@injected.fields}
082 *
083 * @author {@link.uri mailto:ball@hcf.dev Allen D. Ball}
084 */
085@RestController
086@RequestMapping(value = { "/network/nmap/" },
087                produces = MediaType.APPLICATION_XML_VALUE)
088@Service
089@NoArgsConstructor @Log4j2
090public class Nmap extends InetAddressMap<Document> implements XalanConstants {
091    private static final long serialVersionUID = -1039375546367339774L;
092
093    private static final Duration INTERVAL = Duration.ofMinutes(60);
094
095    private static final String NMAP = "nmap";
096    private static final List<String> NMAP_ARGV =
097        Stream.of(NMAP, "--no-stylesheet", "-oX", "-", "-n", "-PS", "-A")
098        .collect(toList());
099
100    /** @serial */ @Autowired private NetworkInterfaces interfaces = null;
101    /** @serial */ @Autowired private ARPCache arp = null;
102    /** @serial */ @Autowired private SSDP ssdp = null;
103    /** @serial */ @Autowired private ThreadPoolTaskExecutor executor = null;
104    /** @serial */ private DocumentBuilderFactory factory = null;
105    /** @serial */ private XPath xpath = null;
106    /** @serial */ private Transformer transformer = null;
107    /** @serial */ private boolean disabled = true;
108
109    @PostConstruct
110    public void init() throws Exception {
111        factory = DocumentBuilderFactory.newInstance();
112
113        xpath = XPathFactory.newInstance().newXPath();
114
115        transformer = TransformerFactory.newInstance().newTransformer();
116        transformer.setOutputProperty(OMIT_XML_DECLARATION, NO);
117        transformer.setOutputProperty(INDENT, YES);
118        transformer.setOutputProperty(XALAN_INDENT_AMOUNT.toString(), String.valueOf(2));
119
120        try {
121            var argv = Stream.of(NMAP, "-version").collect(toList());
122
123            log.info("{}", argv);
124
125            var process =
126                new ProcessBuilder(argv)
127                .inheritIO()
128                .redirectOutput(PIPE)
129                .start();
130
131            try (var in = process.getInputStream()) {
132                new BufferedReader(new InputStreamReader(in, UTF_8)).lines()
133                    .forEach(t -> log.info("{}", t));
134            }
135
136            disabled = (process.waitFor() != 0);
137        } catch (Exception exception) {
138            disabled = true;
139        }
140
141        if (disabled) {
142            log.warn("nmap command is not available");
143        }
144    }
145
146    @PreDestroy
147    public void destroy() { }
148
149    @EventListener(ApplicationReadyEvent.class)
150    @Scheduled(fixedDelay = 30 * 1000)
151    public void update() {
152        if (! isDisabled()) {
153            try {
154                var empty = factory.newDocumentBuilder().newDocument();
155
156                empty.appendChild(empty.createElement("nmaprun"));
157
158                interfaces.stream()
159                    .map(NetworkInterface::getInterfaceAddresses)
160                    .flatMap(List::stream)
161                    .map(InterfaceAddress::getAddress)
162                    .filter(t -> (! t.isMulticastAddress()))
163                    .forEach(t -> putIfAbsent(t, empty));
164
165                arp.keySet().stream()
166                    .filter(t -> (! t.isMulticastAddress()))
167                    .forEach(t -> putIfAbsent(t, empty));
168
169                ssdp.values().stream()
170                    .filter(t -> t instanceof SSDPResponse)
171                    .map(t -> ((SSDPResponse) t).getSocketAddress())
172                    .map(t -> ((InetSocketAddress) t).getAddress())
173                    .forEach(t -> putIfAbsent(t, empty));
174
175                keySet().stream()
176                    .filter(t -> INTERVAL.compareTo(getOutputAge(t)) < 0)
177                    .map(Worker::new)
178                    .forEach(t -> executor.execute(t));
179            } catch (Exception exception) {
180                log.error("{}", exception.getMessage(), exception);
181            }
182        }
183    }
184
185    @RequestMapping(value = { "{ip}.xml" })
186    public String nmap(@PathVariable String ip) throws Exception {
187        var out = new ByteArrayOutputStream();
188
189        transformer.transform(new DOMSource(get(InetAddress.getByName(ip))), new StreamResult(out));
190
191        return out.toString(UTF_8.name());
192    }
193
194    public boolean isDisabled() { return disabled; }
195
196    public Set<Integer> getPorts(InetAddress key) {
197        var ports = new TreeSet<Integer>();
198        var list = (NodeList) get(key, "/nmaprun/host/ports/port/@portid", NODESET);
199
200        if (list != null) {
201            for (int i = 0; i < list.getLength(); i += 1) {
202                ports.add(Integer.parseInt(list.item(i).getNodeValue()));
203            }
204        }
205
206        return ports;
207    }
208
209    public Set<String> getProducts(InetAddress key) {
210        var products = new LinkedHashSet<String>();
211        var list = (NodeList) get(key, "/nmaprun/host/ports/port/service/@product", NODESET);
212
213        if (list != null) {
214            for (int i = 0; i < list.getLength(); i += 1) {
215                products.add(list.item(i).getNodeValue());
216            }
217        }
218
219        return products;
220    }
221
222    private Duration getOutputAge(InetAddress key) {
223        long start = 0;
224        var number = (Number) get(key, "/nmaprun/runstats/finished/@time", NUMBER);
225
226        if (number != null) {
227            start = number.longValue();
228        }
229
230        return Duration.between(Instant.ofEpochSecond(start), Instant.now());
231    }
232
233    private Object get(InetAddress key, String expression, QName qname) {
234        Object object = null;
235        var document = get(key);
236
237        if (document != null) {
238            try {
239                object = xpath.compile(expression).evaluate(document, qname);
240            } catch (Exception exception) {
241                log.error("{}", exception.getMessage(), exception);
242            }
243        }
244
245        return object;
246    }
247
248    @RequiredArgsConstructor @EqualsAndHashCode @ToString
249    private class Worker implements Runnable {
250        private final InetAddress key;
251
252        @Override
253        public void run() {
254            try {
255                var argv = NMAP_ARGV.stream().collect(toList());
256
257                if (key instanceof Inet4Address) {
258                    argv.add("-4");
259                } else if (key instanceof Inet6Address) {
260                    argv.add("-6");
261                }
262
263                argv.add(key.getHostAddress());
264
265                var builder = factory.newDocumentBuilder();
266                var process =
267                    new ProcessBuilder(argv)
268                    .inheritIO()
269                    .redirectOutput(PIPE)
270                    .start();
271
272                try (var in = process.getInputStream()) {
273                    put(key, builder.parse(in));
274
275                    int status = process.waitFor();
276
277                    if (status != 0) {
278                        throw new IOException(argv + " returned exit status " + status);
279                    }
280                }
281            } catch (Exception exception) {
282                remove(key);
283                log.error("{}", exception.getMessage(), exception);
284            }
285        }
286    }
287}