diff --git a/src/main/java/org/metricshub/snmp/client/ISnmpClient.java b/src/main/java/org/metricshub/snmp/client/ISnmpClient.java new file mode 100644 index 0000000..9ff1463 --- /dev/null +++ b/src/main/java/org/metricshub/snmp/client/ISnmpClient.java @@ -0,0 +1,76 @@ +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * SNMP Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +package org.metricshub.snmp.client; + +import java.util.List; + +/** + * Unified abstraction that both {@link SnmpClient} (live network) and + * {@link OfflineSnmpClient} (file‑backed) can implement. + */ +public interface ISnmpClient { + + /** + * Performs SNMP get action for a single OID. + * @param oid A given OID. + * @return The corresponding object as a string. + * @throws Exception on error (e.g. no such OID). + */ + String get(String oid) throws Exception; + + /** + * Performs SNMP getNext action for a single OID. + * @param oid A given OID. + * @return The corresponding object as a string. + * @throws Exception on error (e.g. no such OID). + */ + String getNext(String oid) throws Exception; + + + /** + * Reads an SNMP table. + * + * @param rootOID Root OID of the table (e.g. …7.1) + * @param selectColumns Numeric column indexes or "ID" for the row index + * @return Rows serialized with semicolons (one row per line) + */ + List> table(String rootOID, String[] selectColumns) throws Exception; + + /** + * Strips the leading dot from an OID string if it exists. + * + * @param s the OID string to process. + * @return the OID string without a leading dot. + */ + default String stripDot(String s) { + return s.startsWith(".") ? s.substring(1) : s; + } + + /** + * Performs SNMP walk action starting from a given OID. + * @param oid A given OID. + * @return The corresponding object as a string. + * @throws Exception on error (e.g. no such OID). + */ + String walk(String oid) throws Exception; +} diff --git a/src/main/java/org/metricshub/snmp/client/OfflineSnmpClient.java b/src/main/java/org/metricshub/snmp/client/OfflineSnmpClient.java new file mode 100644 index 0000000..24d124b --- /dev/null +++ b/src/main/java/org/metricshub/snmp/client/OfflineSnmpClient.java @@ -0,0 +1,202 @@ +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * SNMP Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +package org.metricshub.snmp.client; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.stream.Stream; + +/** + * An offline SNMP client that reads OID values from a snmp WALK dump file and executes + * SNMP operations (GET, GET_NEXT, TABLE, WALK) against those OIDs. + */ +public final class OfflineSnmpClient implements ISnmpClient { + + /** Container for Snmp object's type and value. */ + private static final class SnmpValue { + final String type; + final String value; + SnmpValue(String type, String value) { + this.type = type; + this.value = value; + } + } + + // Maps the OIDs to their corresponding SnmpValue (type + value). + private final NavigableMap oidValues = new TreeMap<>(); + + + /** + * Reads oid values from all .walk files in the specified directory. + * @param directory A given directory containing .walk files + * @throws IOException If an I/O error occurs reading from the directory or a file + */ + public OfflineSnmpClient(Path directory) throws IOException { + try (Stream paths = Files.list(directory)) { + paths.filter(path -> path.toString().endsWith(".walk")) + .forEach(file -> { + try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (!line.contains("\t")) continue; + + final String[] parts = line.split("\\t", 3); + if (parts.length < 3) continue; + + final String oid = stripDot(parts[0]); + final String type = parts[1]; + final String value = parts[2]; + oidValues.put(oid, new SnmpValue(type, value)); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + @Override + public String get(String oid) throws Exception { + final String key = stripDot(oid); + final SnmpValue val = oidValues.get(key); + if (val == null) { + throw new Exception("(no-such-oid)"); + } + return val.value; + } + + @Override + public String getNext(String oid) { + final Map.Entry next = oidValues.higherEntry(stripDot(oid)); + if (next == null) { + return "(end-of-mib-view)"; + } + final String nextOid = next.getKey(); + final SnmpValue snmpVal = next.getValue(); + return nextOid + "\t" + snmpVal.type + "\t" + snmpVal.value; + } + + @Override + public List> table(String rootOID, String[] selectColumns) { + if (rootOID == null || rootOID.length() < 3) { + throw new IllegalArgumentException("Invalid SNMP Table OID: " + rootOID); + } + if (selectColumns == null || selectColumns.length < 1) { + throw new IllegalArgumentException("Invalid SNMP Table columns"); + } + + final String base = stripDot(rootOID); + + // find first column under base + final Map.Entry firstEntry = oidValues.higherEntry(base); + if (firstEntry == null || !firstEntry.getKey().startsWith(base + ".")) { + return new ArrayList<>(); // empty table + } + + final String firstKey = firstEntry.getKey(); // e.g. base + ".1." + final int dotAfter = firstKey.indexOf('.', base.length() + 1); + if (dotAfter < 0) return new ArrayList<>(); + + final String firstColOid = firstKey.substring(0, dotAfter); // e.g. "…7.1" + final int colOidLen = firstColOid.length(); + + // collect row IDs + final List ids = new ArrayList<>(); + String cursor = firstColOid; + while (true) { + final Map.Entry next = oidValues.higherEntry(cursor); + if (next == null) break; + final String key = next.getKey(); + if (!key.startsWith(firstColOid + ".")) { + break; + } + final String id = key.substring(colOidLen + 1); + ids.add(id); + cursor = key; + if (ids.size() > 10000) { + break; + } + } + + // build result rows + final List> rows = new ArrayList<>(ids.size()); + for (String id : ids) { + final List row = new ArrayList<>(selectColumns.length); + for (String col : selectColumns) { + if ("ID".equals(col)) { + row.add(id); + } else { + String lookupOid = base + "." + col + "." + id; + try { + row.add(get(lookupOid)); + } catch (Exception e) { + row.add(""); + } + } + } + rows.add(row); + } + return rows; + } + + @Override + public String walk(String oid) { + final String base = stripDot(oid); + final StringBuilder walkResult = new StringBuilder(); + + // Find the first OID that is >= base + Map.Entry entry = oidValues.ceilingEntry(base); + if (entry == null) { + return ""; // Nothing at or after base + } + + while (entry != null) { + final String nextOid = entry.getKey(); + if (!nextOid.startsWith(base + ".") && !nextOid.equals(base)) { + break; // left the subtree + } + + final SnmpValue snmpValue = entry.getValue(); + walkResult.append(nextOid) + .append("\t") + .append(snmpValue.type) + .append("\t") + .append(snmpValue.value) + .append("\n"); + + entry = oidValues.higherEntry(nextOid); + } + + return walkResult.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/org/metricshub/snmp/client/SnmpClient.java b/src/main/java/org/metricshub/snmp/client/SnmpClient.java index 7bcfc34..a2f4c1e 100644 --- a/src/main/java/org/metricshub/snmp/client/SnmpClient.java +++ b/src/main/java/org/metricshub/snmp/client/SnmpClient.java @@ -41,7 +41,7 @@ import java.util.List; import java.util.Set; -public class SnmpClient { +public class SnmpClient implements ISnmpClient { // Default SNMP port number public static final int SNMP_PORT = 161; diff --git a/src/test/java/org/metricshub/snmp/client/OfflineSnmpClientTest.java b/src/test/java/org/metricshub/snmp/client/OfflineSnmpClientTest.java new file mode 100644 index 0000000..ba3517a --- /dev/null +++ b/src/test/java/org/metricshub/snmp/client/OfflineSnmpClientTest.java @@ -0,0 +1,207 @@ +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * SNMP Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 MetricsHub + * ჻჻჻჻჻჻ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Lesser Public License for more details. + * + * You should have received a copy of the GNU General Lesser Public + * License along with this program. If not, see + * . + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +package org.metricshub.snmp.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class OfflineSnmpClientTest { + + private OfflineSnmpClient client; + + @BeforeEach + void setUp() throws IOException { + final Path tempDir = Files.createTempDirectory("snmpWalkDir"); + + // -------- File 1: out.walk (original structure) -------- + final Path file1 = tempDir.resolve("out.walk"); + final String dump1 = + "1.3.6.1.2.1.2.2.1.1.1\tASN_INTEGER\t1\n" + + "1.3.6.1.2.1.2.2.1.1.6\tASN_INTEGER\t6\n" + + "1.3.6.1.2.1.2.2.1.2.1\tASN_OCTET_STR\tlo\n" + + "1.3.6.1.2.1.2.2.1.2.6\tASN_OCTET_STR\tbond0\n"; + Files.write(file1, dump1.getBytes(StandardCharsets.UTF_8)); + + // -------- File 2: extra.walk (table layout) -------- + final Path file2 = tempDir.resolve("extra.walk"); + final String dump2 = + // row 1 + "1.3.6.1.4.1.1.1.1.1\tASN_OCTET_STR\tmyDevice\n" + + "1.3.6.1.4.1.1.1.2.1\tASN_INTEGER\t7\n" + + // row 2 + "1.3.6.1.4.1.1.1.1.2\tASN_OCTET_STR\teth0\n" + + "1.3.6.1.4.1.1.1.2.2\tASN_INTEGER\t99\n"; + Files.write(file2, dump2.getBytes(StandardCharsets.UTF_8)); + + // -------- File 3: another.walk (table layout) -------- + final Path file3 = tempDir.resolve("another.walk"); + final String dump3 = + // row 1 + "1.3.6.1.4.1.1.2.1.1\tASN_INTEGER\t42\n" + + "1.3.6.1.4.1.1.2.2.1\tASN_OCTET_STR\tanswer\n" + + // row 2 + "1.3.6.1.4.1.1.2.1.2\tASN_INTEGER\t123\n" + + "1.3.6.1.4.1.1.2.2.2\tASN_OCTET_STR\tsecond\n"; + Files.write(file3, dump3.getBytes(StandardCharsets.UTF_8)); + + client = new OfflineSnmpClient(tempDir); + + file1.toFile().deleteOnExit(); + file2.toFile().deleteOnExit(); + file3.toFile().deleteOnExit(); + tempDir.toFile().deleteOnExit(); + } + + // --- Tests for File 1 (out.walk) --- + @Test + void testGetExistingOid() throws Exception { + assertEquals("lo", client.get("1.3.6.1.2.1.2.2.1.2.1")); + } + + @Test + void testGetNonExistingOid() { + final Exception exception = assertThrows(Exception.class, + () -> client.get("1.3.6.1.999.1")); + assertEquals("(no-such-oid)", exception.getMessage()); + } + + @Test + void testGetNextFromFirstFile() { + final String next = client.getNext("1.3.6.1.2.1.2.2.1.1.1"); + assertTrue(next.startsWith("1.3.6.1.2.1.2.2.1.1.6")); + assertTrue(next.contains("ASN_INTEGER")); + assertTrue(next.endsWith("6")); + } + + @Test + void testWalkFromFirstFile() { + final String walk = client.walk("1.3.6.1.2.1.2.2.1.1"); + assertTrue(walk.contains("1.3.6.1.2.1.2.2.1.1.1\tASN_INTEGER\t1")); + assertTrue(walk.contains("1.3.6.1.2.1.2.2.1.1.6\tASN_INTEGER\t6")); + assertFalse(walk.contains("bond0")); // bond0 is under .2, not .1 + } + + @Test + void testTableFromFirstFile() { + final List> rows = client.table("1.3.6.1.2.1.2.2.1", new String[]{"1", "2", "ID"}); + assertEquals(2, rows.size()); + + final List row1 = rows.get(0); + assertEquals("1", row1.get(2)); + assertEquals("1", row1.get(0)); + assertEquals("lo", row1.get(1)); + + final List row2 = rows.get(1); + assertEquals("6", row2.get(2)); + assertEquals("6", row2.get(0)); + assertEquals("bond0", row2.get(1)); + } + + // --- Tests for File 2 (extra.walk) --- + @Test + void testGetFromSecondFile() throws Exception { + assertEquals("myDevice", client.get("1.3.6.1.4.1.1.1.1.1")); + assertEquals("7", client.get("1.3.6.1.4.1.1.1.2.1")); + assertEquals("eth0", client.get("1.3.6.1.4.1.1.1.1.2")); + assertEquals("99", client.get("1.3.6.1.4.1.1.1.2.2")); + } + + @Test + void testGetNextFromSecondFile() { + final String next = client.getNext("1.3.6.1.4.1.1.1.1.1"); // myDevice + assertTrue(next.endsWith("eth0")); // because lex order goes to 1.1.2 + } + + @Test + void testWalkFromSecondFile() { + final String walk = client.walk("1.3.6.1.4.1.1.1"); + assertTrue(walk.contains("1.3.6.1.4.1.1.1.1.1\tASN_OCTET_STR\tmyDevice")); + assertTrue(walk.contains("1.3.6.1.4.1.1.1.2.1\tASN_INTEGER\t7")); + assertTrue(walk.contains("1.3.6.1.4.1.1.1.1.2\tASN_OCTET_STR\teth0")); + assertTrue(walk.contains("1.3.6.1.4.1.1.1.2.2\tASN_INTEGER\t99")); + } + + @Test + void testTableFromSecondFile() { + final List> rows = client.table("1.3.6.1.4.1.1.1", new String[]{"1", "2", "ID"}); + assertEquals(2, rows.size()); + + final List row1 = rows.get(0); + assertEquals("myDevice", row1.get(0)); + assertEquals("7", row1.get(1)); + assertEquals("1", row1.get(2)); + + final List row2 = rows.get(1); + assertEquals("eth0", row2.get(0)); + assertEquals("99", row2.get(1)); + assertEquals("2", row2.get(2)); + } + + // --- Tests for File 3 (another.walk) --- + @Test + void testGetFromThirdFile() throws Exception { + assertEquals("42", client.get("1.3.6.1.4.1.1.2.1.1")); + assertEquals("answer", client.get("1.3.6.1.4.1.1.2.2.1")); + assertEquals("123", client.get("1.3.6.1.4.1.1.2.1.2")); + assertEquals("second", client.get("1.3.6.1.4.1.1.2.2.2")); + } + + @Test + void testWalkFromThirdFile() { + final String walk = client.walk("1.3.6.1.4.1.1.2"); + assertTrue(walk.contains("1.3.6.1.4.1.1.2.1.1\tASN_INTEGER\t42")); + assertTrue(walk.contains("1.3.6.1.4.1.1.2.2.1\tASN_OCTET_STR\tanswer")); + assertTrue(walk.contains("1.3.6.1.4.1.1.2.1.2\tASN_INTEGER\t123")); + assertTrue(walk.contains("1.3.6.1.4.1.1.2.2.2\tASN_OCTET_STR\tsecond")); + } + + @Test + void testGetNextFromThirdFile() { + final String next = client.getNext("1.3.6.1.4.1.1.2.1.1"); // 42 + assertTrue(next.endsWith("123")); // because lex order goes to 1.2 + } + + @Test + void testTableFromThirdFile() { + final List> rows = client.table("1.3.6.1.4.1.1.2", new String[]{"1", "2", "ID"}); + assertEquals(2, rows.size()); + + final List row1 = rows.get(0); + assertEquals("42", row1.get(0)); + assertEquals("answer", row1.get(1)); + assertEquals("1", row1.get(2)); + + final List row2 = rows.get(1); + assertEquals("123", row2.get(0)); + assertEquals("second", row2.get(1)); + assertEquals("2", row2.get(2)); + } +}