commit 515d053f59544ec264bac7bf1ebc0d16a41d0185 Author: Janis Jansons Date: Fri Jul 24 20:26:37 2026 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c49575 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Your own values: ONT serial/MAC/versions, router + admin addresses. +# Copy example.env -> .env and fill it in. Never commit .env. +.env + +# Flash dumps and their checksums — keep LOCAL, never commit/push. They are +# device-unique and contain per-device calibration, MAC, serial, PLOAM. +backups/ +*.bin + +# As-built notes for THIS line — contain public IPs, the real GPON serial/MAC, +# LAN ranges and port-forward targets. README.md is the shareable guide. +# Delete this line if you have sanitised docs/ and want to publish them. +docs/ + +# Python +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..127c6a2 --- /dev/null +++ b/README.md @@ -0,0 +1,522 @@ +# tet-fiber-sfp + +Replace an ISP-supplied **Nokia GPON ONT** with a **D-Link DPN-100** GPON SFP ONT stick +(Realtek RTL9601CI) seated directly in a **MikroTik hAP ac** (`sfp1`), by cloning the +ONT's identity onto the stick. No extra box, no extra PSU, no extra Ethernet hop. + +Done against **TET** (Latvia) on a Nokia OLT with a **G-2426G-A** ONT. It registered +**O5 / OPERATION on the first fibre connect** — no fake-O5 loop — and pulled a public +DHCP lease on the WAN VLAN. The method is generic to Nokia-OLT GPON ISPs; the exact +identity values and the WAN VLAN are yours to discover. + +> **Scope of this README:** the full procedure, with every line-specific value replaced +> by a placeholder. Put your real values in `.env` (gitignored) — see +> [`example.env`](example.env). + +--- + +## Contents + +1. [Before you start](#1-before-you-start) +2. [Read your ONT's identity](#2-read-your-onts-identity-do-this-first) +3. [Bench setup — talk to the stick](#3-bench-setup--talk-to-the-stick) +4. [Configure `.env`](#4-configure-env) +5. [Write the clone values](#5-write-the-clone-values) +6. [Reboot, verify, connect the fibre](#6-reboot-verify-connect-the-fibre) +7. [Router side — WAN VLAN, DHCP, firewall](#7-router-side--wan-vlan-dhcp-firewall) +8. [Lock down the stick's management](#8-lock-down-the-sticks-management) +9. [Revert to factory](#9-revert-to-factory) +10. [Troubleshooting](#10-troubleshooting) +11. [Tools reference](#11-tools-reference) +12. [Known unknowns](#12-known-unknowns) + +--- + +## 1. Before you start + +**Hardware** + +| Thing | Notes | +|---|---| +| D-Link DPN-100 GPON SFP ONT | Rev C1 here, stock firmware `R4.2.104.033e` ("Wind"), 16 MB SPI-NOR, Realtek RTL9601CI | +| MikroTik with an SFP cage | hAP ac (RB962UiGS), RouterOS 7.x. Any RouterOS box with `sfp1` works | +| Your ISP's ONT | still connected, so you can read its identity **before** you swap | +| USB-UART / serial adapter | *strongly recommended* — the only recovery path if you brick the stick | + +**Know what you're doing here** + +- You are presenting a different device to your ISP's OLT under your own ONT's identity. + Whether that's acceptable is between you and your contract — check it. Only ever clone + **your own** ONT, on **your own** line. +- Two ONTs with the same serial must never be on the fibre at once. Unplug the original. +- The ISP keeps OMCI control of whatever ONT is on the line. That's inherent to GPON. +- The `mtd1` "Config" partition holds this stick's unique serial, PLOAM password and **RF + calibration** — there is no download for it. Nothing in this guide writes to it directly, + but if you want a recovery copy before you start, `tools/gpon_backup.py` dumps a partition + over telnet and md5-verifies it (`python3 tools/gpon_backup.py 1 mtd1_config.bin`). +- Class 1 laser. Don't look into the fibre or the SFP's port. + +--- + +## 2. Read your ONT's identity (do this first) + +While the ISP's ONT is still up, log in to its **web UI** (its LAN address is on the +label) and go to **Maintenance → Log**. Everything you need is in the **first few lines**, +in one place — that's the ONT reporting its TR-069 `DeviceInfo` parameters to the ISP's ACS +at boot, so the whole identity set appears together. The individual values are also +scattered across the status pages, but the log saves you the hunt. + +| `.env` key | Field in **Maintenance → Log** | What it is (OMCI) | +|---|---|---| +| `ONT_VENDOR` | `Manufacturer` | GPON **vendor ID**. Nokia/Alcatel-Lucent reports `ALCL` — the same 4 chars the serial starts with, so the two cross-check each other | +| `ONT_SERIAL` | `SerialNumber` | GPON **serial number** — the OLT's primary ONT identifier. 12 chars = 4 ASCII vendor bytes + 4 bytes rendered as 8 hex chars | +| `ONT_EQID` | `ProductClass` | **Equipment ID** (ME257 ONT2-G) = the model string | +| `ONT_HWVER` | `HWVer` | **Hardware version** (ME256 ONT-G) | +| `ONT_SWVER` | `SWVer` | **Software version** (ME7). If it carries a bracketed build suffix like `XXXXXXXXXXXXXX(1.2345.678)`, the OMCI value is the **14-char prefix** without it | +| `ONT_MAC` | *not in the log* | The ONT's own MAC — **printed on the box label** | + +Cross-check what you can against the physical labels: the serial and model are printed on +the ONT itself, and the hardware version is usually the string the box label calls +**"ONT P/N"**. Two independent sources per value is worth the thirty seconds. + +Also worth screenshotting before you lose access: the ONT's WAN service list (which +VLAN ID carries internet vs VoIP vs TR-069) and its optical levels (Rx/Tx dBm), so you +have a reference for what "healthy" looks like on your line. + +**Two things you probably do not need:** + +- **PLOAM password** — not exposed in the Nokia UI. On the line this was done against, + the OLT authenticates by **serial only** and registration worked without it. Leave the + stick's factory value; only revisit if the OLT rejects you with a password error. +- **The MAC, in bridge mode.** `mac1` is the *stick's own* identity (its management + interface). The stick bridges transparently, so the L2 source your ISP's DHCP/BNG + actually sees is your **router's** `sfp1.` MAC — which you never change, and it + still works. Cloning `mac1` is faithful and harmless, but it is not in the data path. + It would only matter if you ran the stick in **router** mode. + +--- + +## 3. Bench setup — talk to the stick + +Seat the stick in `sfp1` **with no fibre attached**. The host-side link will not come up +until you tell RouterOS to ignore loss-of-signal: + +``` +/interface ethernet set sfp1 sfp-ignore-rx-los=yes +``` + +`sfp1` should now show `link-ok` at 1 Gbps. The stick's management lives **untagged** on +`sfp1` at its factory address `192.168.100.1` (telnet, `root`/`admin` — the documented +D-Link/Realtek defaults for this build). Give the router an address in that subnet: + +``` +/ip address add address=192.168.100.2/24 interface=sfp1 comment=gpon-bench-test +``` + +**Reaching it from a workstation.** Do **not** try to `dst-nat` to `192.168.100.1` — +RouterOS marks that rule INVALID. Source-NAT out of `sfp1` instead, so the stick replies +to an address it can route to: + +``` +/ip firewall nat add chain=srcnat out-interface=sfp1 action=masquerade \ + src-address= comment=gpon-bench-test +``` + +Your workstation then reaches `192.168.100.1` via its default gateway. Check it: + +```bash +cp example.env .env # then edit .env — see step 4 +python3 tools/gpon_cli.py 'system' 'ontver' +``` + +**The CLI is not a shell.** Login drops you into the restricted Realtek OMCI CLI, +prompt `#ONT>`. Two contexts matter: + +``` +#ONT> system -> #ONT/system> +#ONT/system> misc -> #ONT/system/misc> EEPROM identity fields ( get|set) +#ONT/system> shell -> #ONT/system/shell> real busybox root shell (uid=0) +``` + +Other menus: `#ONT/traffic>` → `{pon,eth,voip,omci,router,hpna}`; +`#ONT/system>` → `{mib,log,env,misc,manu,fs,ippath,net,shell,debug,ontver,custom}`. + +The stick has **no default route** — it can only reach its own `/24`. + +--- + +## 4. Configure `.env` + +```bash +cp example.env .env +$EDITOR .env +``` + +`GPON_HOST` / `GPON_PORT` / `GPON_USER` / `GPON_PASS` are the stick's factory defaults and +are read by everything in `tools/`. Fill in the `ONT_*` values from step 2, and the +`STICK_*` values from the read-back below, which are your revert path. + +`.env` is gitignored. `example.env` is the shareable template. Resolution order is +process environment → `.env` → `example.env`, so you can also do a one-off with +`GPON_HOST=… python3 tools/…`. + +Record the stick's own factory identity into `.env` now, before you overwrite it: + +```bash +python3 tools/gpon_cli.py 'system' 'misc' \ + 'vendor get' 'eqsn get' 'eqid get' 'eqvid get' 'mac1 get' 'pon_passwd get' +``` + +--- + +## 5. Write the clone values + +Five fields are direct EEPROM writes in `#ONT/system/misc`. The sixth (software version) +is not an EEPROM field at all. + +### 5a. EEPROM identity + +```bash +set -a; . ./.env; set +a +python3 tools/gpon_cli.py 'system' 'misc' \ + "vendor set \"$ONT_VENDOR\"" \ + "eqsn set \"$ONT_SERIAL\"" \ + "eqid set \"$ONT_EQID\"" \ + "eqvid set \"$ONT_HWVER\"" \ + "mac1 set $ONT_MAC" +``` + +Each `set` returns `---ATECMDRESULT--- OK`. Read them back with ` get`. + +> A read-back of `eqsn` may show the trailing 8 characters **lowercased**. That is +> cosmetic: the serial's second half is stored as 4 binary bytes and re-rendered as +> lowercase hex. The 8 bytes the OLT checks are the 4 ASCII vendor bytes followed by +> those 4 binary bytes, which are unchanged. + +### 5b. Software version (ME7) + +The SW version is read at boot from a config file, not from EEPROM. The Realtek config +loader (`libvos.so`) reads `/etc/sys.cfg`, `/tmp/sys.cfg`, **`/mnt/rwdir/sys.cfg`**, +`/mnt/backupdir/etc/sys.cfg` in order and **merges** them per key — so a one-line overlay +overrides only `SWVER` and leaves the read-only cramfs base untouched: + +```bash +set -a; . ./.env; set +a +python3 tools/gpon_cmd.py "echo SWVER=$ONT_SWVER > /mnt/rwdir/sys.cfg" 'sync' 'cat /mnt/rwdir/sys.cfg' +``` + +### 5c. Leave these alone (with fallbacks if it doesn't register) + +| Field | Leave at | Why | +|---|---|---| +| `misc olt_vendor` | `0` (auto) | Stock `/etc/sys.cfg` ships `MUL_VENDOR_EN=2` + an `EXPECT_VENDOR_ID` list covering ALU/Nokia auto-adapt, and the DPN-100 C1 guides also leave it. **Fallback on fake-O5:** try `21`, then `1`, then `3` | +| `misc pon_passwd` (PLOAM) | factory | Serial-based auth. **Fallback:** set it only if the OLT rejects with a password error | +| `misc mac2`, `hwver`, `mpcode`, `mpsn`, `mhwv` | unchanged | Manufacturing/secondary fields, not checked by the OLT | + +--- + +## 6. Reboot, verify, connect the fibre + +EEPROM writes are immediate and persistent; `sys.cfg` is only read at boot. So a clean +**reboot** applies everything: + +```bash +python3 tools/gpon_cmd.py 'reboot' +python3 tools/gpon_verify.py 'system' 'ontver' 'misc' \ + 'vendor get' 'eqsn get' 'eqid get' 'eqvid get' 'mac1 get' +``` + +> ⚠ Do **not** use `#ONT/system/mib` → `reset` as a "commit". It resets the runtime MIB to +> defaults; it is not a save. + +`gpon_verify.py` waits for telnet to drop (confirming the reboot actually began), waits +for it to come back, then reads the values. The one that proves the overlay worked is +`ontver` — it can only have changed from the stock firmware string if `/mnt/rwdir/sys.cfg` +was read at boot. + +Now **unplug the fibre from the ISP's ONT and plug it into the stick.** Then: + +```bash +python3 tools/gpon_cli.py 'traffic' 'pon' 'show link' +``` + +Want: + +``` +Link State: ACTIVE +Operation State Machine: OPERATION (O5) +``` + +`show alarm` in the same menu lists PON MAC alarms. If you're stuck below O5, see +[Troubleshooting](#10-troubleshooting). + +--- + +## 7. Router side — WAN VLAN, DHCP, firewall + +### ⚠ Do the firewall first. Read this before you add the DHCP client. + +A stock RouterOS `input` chain typically protects the WAN with **one rule**: +`drop chain=input in-interface=ether1`. That is not a catch-all — the chain otherwise +**default-accepts**, and `api` (8728), `api-ssl` (8729) and `ssh` (22) commonly listen with +no address restriction. They were safe only because `ether1` was the single public interface. + +On the line this was built against, the new VLAN interface got its public IP **before** it +was added to that drop — and the router's API was reachable from the internet and being +brute-forced within minutes. (Failed logins only, no breach: verified no rogue users, no +scheduler entries, no socks, and the only successful logins were from the LAN.) + +**The rule: never let a public IP land on a MikroTik interface that is not already in the +WAN drop set.** Two ordering facts make this concrete: + +- An **`input`** rule matching an interface **list** is valid immediately, so you can lock + it down before any address exists. +- A **`forward`** rule with `in-interface=X` is flagged `I`/INVALID while `X` has no IP, so + it cannot be pre-created — it goes valid the moment DHCP binds. + +So: add the interface to the WAN list and fix the input drop **first**, then add the DHCP +client, then add the forward drop. + +### Config + +``` +# 1) VLAN sub-interface on the stick port. Verify the VLAN ID for YOUR ISP (below). +/interface vlan add name=sfp1. interface=sfp1 vlan-id= comment=gpon-wan + +# 2) FIRST: treat it as WAN. Add to the interface-list your input drop keys on... +/interface list member add list=WAN interface=sfp1. comment=gpon-wan + +# ...and convert the single-interface input drop to match the list +/ip firewall filter ... chain=input action=drop in-interface-list=WAN \ + comment="WAN mgmt lockout (all WAN interfaces)" + +# 3) THEN the DHCP client. add-default-route=no keeps your existing WAN primary +# while you test; use-peer-dns=no avoids clobbering your resolvers. +/ip dhcp-client add interface=sfp1. add-default-route=no use-peer-dns=no \ + comment=gpon-wan-v4 disabled=no + +# 4) Once the lease binds: the "drop from WAN not dstnat" forward rule for it +/ip firewall filter add chain=forward action=drop connection-state=new \ + connection-nat-state=!dstnat ipsec-policy=in,none in-interface=sfp1. \ + comment="drop from WAN not dstnat (fibre)" +``` + +Then confirm the fibre actually passes traffic without disturbing your primary WAN: + +``` +/ping 8.8.8.8 src-address= interface=sfp1. +``` + +### Finding your WAN VLAN + +The VLAN IDs listed in the ONT's own WAN service table are **ONT-internal C-VLANs**. What +your router sees through the stick is the tag on the Ethernet side, which OMCI translation +may set differently — community reports of four-digit S-VLANs are about the OLT side of +the fibre, which you never see here. On this line the Ethernet-side internet VLAN turned +out to be the same low ID the ONT listed for INTERNET, and **untagged got no DHCP reply +at all**. + +Determine it empirically: with the stick registered at O5, either try a DHCP client on the +ONT's listed internet VID first, or sniff for tagged frames — +`/tool sniffer quick interface=sfp1` and read the VLAN column. + +Note that WAN (tagged) and the stick's management (untagged) coexist on `sfp1` with no +conflict, so you keep telnet access while the WAN is live. + +### Cutting over for real + +Deliberately *not* done in the reference setup — the fibre was left present-but-not-default. +To actually use it: + +1. **Masquerade:** add the VLAN interface to whatever your `srcnat` rules key on, or add + `chain=srcnat out-interface=sfp1. action=masquerade`. +2. **Default route:** `/ip dhcp-client set [find comment=gpon-wan-v4] add-default-route=yes` + — either as primary, or with a higher `default-route-distance` for failover behind your + existing WAN. +3. **Re-add, deliberately, any service accepts and `dst-nat` port-forwards** you want + reachable on the fibre IP. Until you do, nothing is forwarded in and management is + dropped. Think before you duplicate port-forwards: doing so publishes those internal + hosts on a second public IP. + +Or route **one host** out of the fibre and leave everyone else alone. RouterOS 7.22 rejects +the older mangle `action=mark-routing` form (comes up INVALID) — use `/routing rule`: + +``` +/routing table add name=via-fibre fib +/ip route add dst-address=0.0.0.0/0 gateway= routing-table=via-fibre comment="pbr: fibre default" +/ip firewall nat add chain=srcnat out-interface=sfp1. action=masquerade comment="pbr: masq to fibre" +# keep RFC1918 on the main table, send everything else out the fibre +/routing rule add src-address= dst-address=10.0.0.0/8 action=lookup table=main comment=pbr +/routing rule add src-address= dst-address=172.16.0.0/12 action=lookup table=main comment=pbr +/routing rule add src-address= dst-address=192.168.0.0/16 action=lookup table=main comment=pbr +/routing rule add src-address= action=lookup table=via-fibre comment="pbr SWITCH: disable to revert" +``` + +Disabling that last rule is the whole revert. Existing connections stay on the old WAN +until they reconnect — only new flows move. If new flows don't switch, exclude the host +from FastTrack. + +### RouterOS gotchas + +- **`in-interface=""` does not clear an interface matcher** — it errors with "ambiguous + value". To convert a rule: capture its `.id`, `add` the replacement with + `place-before=$id`, then `remove $id`. +- Over SSH, keep a `:local` and its use **on one line** — each line is its own scope. +- Label everything with `comment=` (see the cleanup list at the end of this section) so + you can find and remove your test objects later. + +**Objects this procedure creates:** `/interface vlan sfp1.`; that interface in +the `WAN` interface list; `/ip dhcp-client` on it; `/ip address 192.168.100.2/24` on +`sfp1`; the `srcnat` masquerade to the stick; the input and forward firewall rules. + +--- + +## 8. Lock down the stick's management + +On stock firmware the telnet password **cannot be changed**, so control access at the +router. The stick is not internet-reachable to begin with — private address, no `dst-nat` +to it, the WAN forward-drop covers it, and it does not bridge management onto the WAN VLAN +— but by default any LAN host can reach it via the connected route. + +Restrict it to one admin host, and scope the return-path masquerade to match: + +``` +/ip firewall filter add chain=forward action=accept src-address= \ + dst-address=192.168.100.0/24 comment="gpon-stick: allow admin host" \ + place-before=[:pick [/ip firewall filter find chain=forward] 0] +/ip firewall filter add chain=forward action=drop dst-address=192.168.100.0/24 \ + comment="gpon-stick: block LAN from stick mgmt" \ + place-before=[:pick [/ip firewall filter find chain=forward] 0] +/ip firewall nat set [find comment=gpon-bench-test] src-address= +``` + +The router's own traffic isn't forward-filtered, so it can still reach the stick. Reopen by +disabling the two `gpon-stick` rules. For a genuinely changeable password plus SSH you need +community firmware — do that with the UART adapter as your recovery net. + +--- + +## 9. Revert to factory + +From the `STICK_*` values you recorded in step 4: + +```bash +set -a; . ./.env; set +a +python3 tools/gpon_cli.py 'system' 'misc' \ + "vendor set \"$STICK_VENDOR\"" \ + "eqsn set \"$STICK_SERIAL\"" \ + "eqid set \"$STICK_EQID\"" \ + "eqvid set \"$STICK_HWVER\"" \ + "mac1 set $STICK_MAC" +python3 tools/gpon_cmd.py 'rm /mnt/rwdir/sys.cfg' 'sync' 'reboot' +``` + +Removing the overlay restores the base `SWVER` from the read-only cramfs `/etc/sys.cfg`. + +To undo the router side: remove the objects listed at the end of step 7, and +`/interface ethernet set sfp1 sfp-ignore-rx-los=no`. + +If the stick is bricked rather than merely misconfigured, you need the UART adapter and a +dump of its `mtd1` Config partition — which is why it's worth taking one first. + +--- + +## 10. Troubleshooting + +**Stuck at O1, or fake-O5 (registers then drops in a loop).** Fake-O5 usually means the OLT +is trying a firmware-download handshake it doesn't like the answer to. In order: re-check +`eqsn` for a typo (it is *the* identifier); confirm `ontver` reflects `ONT_SWVER`; then try +`misc olt_vendor set 21`, then `1`, then `3`, rebooting between each. If the OLT complains +about a password specifically, set `pon_passwd`. If it still refuses, the remaining lever is +community firmware (below). + +**No DHCP on the WAN VLAN.** Confirm O5 first — nothing works below it. Then confirm you +have the right VLAN ID (untagged will not answer), that the VLAN interface is on `sfp1`, and +that the DHCP client is enabled and not stuck at "searching". + +**`sfp1` link never comes up.** `sfp-ignore-rx-los=yes` is required without fibre. Check +`/interface ethernet monitor sfp1` and re-seat the module. + +**Stick doesn't come back after reboot.** `gpon_verify.py` gives up after ~140 s; raise +`--wait`. If it truly doesn't return, re-seat it and check `sfp1`. + +**`flash get` / `flash set` fail.** Broken on this stock build — `libmib.so.0` is missing. +Use the OMCI CLI (`misc set`) for identity instead. The `flash` tool only becomes +usable after a community reflash, so ignore guides that lead with it. + +**Temperature reads 0 / the router reports a bad SFP EEPROM checksum.** Both are expected +and neither is fixable in software: the temperature sensor isn't populated +(`misc showtemp` → `temp = 0`), and the transceiver's DDM block has a bad checksum, so +RouterOS shows `eeprom-checksum: bad` and no DDM. These sticks run genuinely hot; manage it +physically (airflow, a small heatsink on the exposed end). **Your real health signal is the +registration state, not temperature** — a thermal or link fault shows up as PON dropping +`O5 → O1` or `sfp1` flapping. Also note `misc alarmstat` reports `ALARM1`/`ALARM2`/`DOOROPEN` +as "yes" — spurious on an SFP form factor, ignore it. + +**Read-only diagnostics** (need the stick's management address reachable): + +| Command | Shows | +|---|---| +| `traffic/pon > show link` | PON registration state — want `ACTIVE` / `OPERATION (O5)` | +| `traffic/pon > show alarm` | PON MAC alarms | +| `system > ontver` | OMCI software version (should equal `ONT_SWVER`) | +| `system/misc > eth_status`, `CPU_inform` | Ethernet status, CPU (reports RTL8672) | +| `system/misc > showtemp`, `alarmstat` | unusable on this stick, see above | + +**Reflashing (last resort, functional reasons only).** Newer community builds have working +`flash get/set` and better Nokia OMCI / fake-O5 handling. **Match the flash size** — +this stick is 16 MB, so a 16 MB build is the right target and an 8 MB one is not. Do it +with the UART adapter connected as your recovery path, never blind over telnet. + +--- + +## 11. Tools reference + +Python 3, standard library only, no dependencies. Connection settings come from `.env` +(see step 4); run them from the repo root. + +| Script | Purpose | +|---|---| +| `tools/gpon_cli.py` | Drive the `#ONT` OMCI CLI — navigates menus, one argument per CLI line | +| `tools/gpon_cmd.py` | Drive the busybox root shell (enters `system` → `shell` for you) | +| `tools/gpon_verify.py` | Wait out a reboot (down, then up), then run CLI reads | +| `tools/gpon_backup.py` | Dump one MTD partition over telnet, md5-verified | +| `tools/gpon_telnet.py` | Minimal raw telnet driver, for poking at anything else | +| `tools/gpon_env.py` | Shared `.env` loader (not run directly) | + +```bash +python3 tools/gpon_cli.py 'traffic' 'pon' 'show link' +python3 tools/gpon_cmd.py 'cat /proc/mtd' 'ls -la /mnt/rwdir' +python3 tools/gpon_backup.py 1 mtd1_config.bin +python3 tools/gpon_telnet.py --host 192.168.100.1 --port 23 --wait 3 '?' +``` + +All of them speak enough of the telnet protocol to refuse option negotiation and strip +IAC sequences, and read until the prompt returns or an idle timeout expires (`--idle`, +`--cap`) — the stick is slow and chatty, so raise those rather than assuming a hang. + +**Repo layout** + +``` +README.md this guide +example.env template -> copy to .env (gitignored) +tools/ Python telnet drivers for the stick +``` + +--- + +## 12. Known unknowns + +- **`OMCI_SW_VER2` — the standby image version.** A Nokia ONT reports **two different** + software versions (active and standby). `SWVER` sets the active ME7 value, and on this + stick both slots then report it. It registered fine — but if an OLT ever runs a + fake-O5 firmware-download sequence that requires the two to differ, this is what to + revisit, and the standby string is not obtainable from the ONT's UI. +- **ISP fleet firmware updates.** If your ISP pushes a software update to its ONT fleet, + the cloned `ONT_SWVER` may need updating to match — reported by others on Nokia OLTs. + +## Credits + +The hack-gpon project's DPN-100 / Realtek documentation, and the Latvian GPON-SFP +community discussion (notably `@KristapsStudy`'s thread on cloning to a Nokia OLT) for the +`OMCI_SW_VER1`/`VER2` + `OMCI_OLT_MODE` leads. diff --git a/example.env b/example.env new file mode 100644 index 0000000..29c83e9 --- /dev/null +++ b/example.env @@ -0,0 +1,64 @@ +# Copy to .env and fill in your own values. .env is gitignored — it holds the +# values that identify YOUR line and YOUR network, and must never be committed. +# +# cp example.env .env +# +# GPON_* are read by the scripts in tools/ (via tools/gpon_env.py). +# The ONT_*/STICK_*/router values are consumed by the copy-paste snippets in +# README.md (shell snippets source this file; RouterOS snippets use ). + + +# --------------------------------------------------------------------------- +# Stick management (telnet) — these are the DPN-100 factory defaults and are +# safe to share; override only if your stick differs. +# --------------------------------------------------------------------------- +GPON_HOST=192.168.100.1 +GPON_PORT=23 +GPON_USER=root +GPON_PASS=admin + + +# --------------------------------------------------------------------------- +# Clone target — YOUR ISP-issued ONT's identity. Read these off the ONT's own +# web UI before you unplug it: Maintenance -> Log, first few lines, has all of +# them in one place (MAC is on the box label). See README step 2. +# --------------------------------------------------------------------------- +# GPON vendor ID: first 4 chars of the GPON serial (e.g. ALCL for Nokia/Alcatel) +ONT_VENDOR=XXXX +# Full 12-char GPON serial number, e.g. XXXXnnnnnnnn +ONT_SERIAL=XXXXXXXXXXXX +# Equipment ID = the ONT model string (OMCI ME257) +ONT_EQID=X-0000X-X +# Hardware version (OMCI ME256); often printed as "ONT P/N" on the box label +ONT_HWVER=0XX00000XXXX +# Software version (OMCI ME7) — the 14-char prefix of the UI's "Software Version" +ONT_SWVER=0XX00000XXXX00 +# The ONT's own MAC. Only matters if the stick runs in router mode; cloned for +# faithfulness. In bridge mode your router's MAC is what the ISP's DHCP sees. +ONT_MAC=00:00:00:00:00:00 + + +# --------------------------------------------------------------------------- +# The stick's OWN factory values — record these BEFORE overwriting them, they +# are your revert path (README step 9). Device-unique. +# --------------------------------------------------------------------------- +STICK_VENDOR=DLNK +STICK_SERIAL=DLNKxxxxxxxx +STICK_EQID=DPN-100 +STICK_HWVER=1.0.0 +STICK_MAC=00:00:00:00:00:00 +# Factory PLOAM password, from `misc pon_passwd get` — usually not needed +STICK_PLOAM=xxxxxxxxxx + + +# --------------------------------------------------------------------------- +# Your network (used by README's router-side steps) +# --------------------------------------------------------------------------- +# How you reach the MikroTik itself +ROUTER_ADDR=192.0.2.1 +# The single host allowed to reach the stick's telnet management +ADMIN_HOST=192.0.2.2 +# Address the router puts on sfp1 to talk to the stick (same /24 as GPON_HOST) +STICK_MGMT_ADDR=192.168.100.2/24 +# The ISP's WAN VLAN on the Ethernet side of the stick — VERIFY THIS YOURSELF +WAN_VLAN=4 diff --git a/tools/gpon_backup.py b/tools/gpon_backup.py new file mode 100755 index 0000000..2268edd --- /dev/null +++ b/tools/gpon_backup.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Back up one MTD partition of the GPON stick over telnet, md5-verified. +Usage: gpon_backup.py [idle_timeout] [hard_cap] +Streams `cat /dev/mtdN | uuencode` over the telnet session and uudecodes locally +(Python's uu module was removed in 3.13, so we decode by hand).""" +import socket, sys, time, re, hashlib +from gpon_env import HOST, PORT, USER, PASS + +idx = sys.argv[1] +outfile = sys.argv[2] +idle_to = float(sys.argv[3]) if len(sys.argv)>3 else 12.0 +hard_cap = float(sys.argv[4]) if len(sys.argv)>4 else 300.0 + +def strip_iac(data, outbuf): + res=bytearray(); i=0 + while iidle: break + if time.time()-start>cap: break + return buf.decode('latin-1','replace') + +def send(x): s.sendall(x.encode()+b'\r\n') + +out=rx(2,8) +if re.search(r'(ogin|sername)',out): send(USER); out+=rx(2,6) +if re.search(r'assword',out,re.I): send(PASS); out+=rx(2,6) +send('system'); rx(1.5,5) +send('shell'); rx(1.5,5) + +send('md5sum /dev/mtd%s'%idx) +m=rx(90,150, stop=lambda b: re.search(rb'[0-9a-f]{32}\s+/dev/mtd',b)) +mm=re.search(r'([0-9a-f]{32})\s+/dev/mtd%s'%re.escape(idx), m) +expected=mm.group(1) if mm else None + +t0=time.time() +send('cat /dev/mtd%s | uuencode p'%idx) +def done(b): + bi=b.find(b'begin ') + return bi>=0 and (b'\nend\r' in b[bi:] or b'\nend\n' in b[bi:]) +d=rx(idle_to, hard_cap, stop=done) +dt=time.time()-t0 +try: s.close() +except Exception: pass + +def uudecode(text): + out=bytearray(); started=False + for ln in text.split('\n'): + ln=ln.rstrip('\r') + if not started: + if ln.startswith('begin '): started=True + continue + if ln=='end': break + if ln=='' or ln=='`': continue + n=(ord(ln[0])-0x20)&0x3f + if n<=0: continue + data=ln[1:]; dec=bytearray() + for i in range(0,len(data),4): + g=data[i:i+4]; v=[(ord(c)-0x20)&0x3f for c in g] + while len(v)<4: v.append(0) + dec.append(((v[0]<<2)|(v[1]>>4))&0xff) + dec.append(((v[1]<<4)|(v[2]>>2))&0xff) + dec.append(((v[2]<<6)|v[3])&0xff) + out.extend(dec[:n]) + return bytes(out) + +raw=uudecode(d) +with open(outfile,'wb') as f: f.write(raw) +actual=hashlib.md5(raw).hexdigest() +print("mtd%s -> %s : %d bytes in %.1fs"%(idx,outfile,len(raw),dt)) +print(" expected md5 (stick): %s"%expected) +print(" actual md5 (local): %s"%actual) +print(" VERIFY: %s"%("OK" if (expected and actual==expected) else "*** MISMATCH / INCOMPLETE ***")) diff --git a/tools/gpon_cli.py b/tools/gpon_cli.py new file mode 100755 index 0000000..ea1ab7e --- /dev/null +++ b/tools/gpon_cli.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Drive the stick's #ONT OMCI CLI (NOT the busybox shell). Stays at #ONT> and navigates menus. +Usage: gpon_cli.py [--idle S] [--cap S] 'cmd1' 'cmd2' ... +Sends each cmd as a CLI line (e.g. 'system', 'misc', '?', 'eqsn get').""" +import socket, sys, time, re +from gpon_env import HOST, PORT, USER, PASS +args=sys.argv[1:]; idle=8.0; cap=60.0; cmds=[] +while args: + a=args.pop(0) + if a=='--idle': idle=float(args.pop(0)) + elif a=='--cap': cap=float(args.pop(0)) + else: cmds.append(a) + +def strip_iac(data,outbuf): + res=bytearray(); i=0 + while iidle: break + if time.time()-start>cap: break + return buf.decode('latin-1','replace') +def send(x): s.sendall(x.encode()+b'\r\n') + +o=rx(3,8) +if re.search(r'(ogin|sername)',o): send(USER); o+=rx(2,6) +if re.search(r'assword',o,re.I): send(PASS); o+=rx(3,8) +# now sitting at #ONT> (do NOT enter shell) +promptend=lambda b: b.rstrip().endswith(b'>') +for c in cmds: + send(c) + out=rx(idle,cap,stop=promptend) + print("\n>>> %s\n%s"%(c,out)) +try: s.close() +except Exception: pass diff --git a/tools/gpon_cmd.py b/tools/gpon_cmd.py new file mode 100755 index 0000000..c504f97 --- /dev/null +++ b/tools/gpon_cmd.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run commands in the stick's root shell over telnet (idle-tolerant, waits for prompt). +Usage: gpon_cmd.py [--idle S] [--cap S] 'cmd1' 'cmd2' ...""" +import socket, sys, time, re +from gpon_env import HOST, PORT, USER, PASS +args=sys.argv[1:]; idle=8.0; cap=120.0; cmds=[] +while args: + a=args.pop(0) + if a=='--idle': idle=float(args.pop(0)) + elif a=='--cap': cap=float(args.pop(0)) + else: cmds.append(a) + +def strip_iac(data,outbuf): + res=bytearray(); i=0 + while iidle: break + if time.time()-start>cap: break + return buf.decode('latin-1','replace') +def send(x): s.sendall(x.encode()+b'\r\n') + +o=rx(2,8) +if re.search(r'(ogin|sername)',o): send(USER); o+=rx(2,6) +if re.search(r'assword',o,re.I): send(PASS); o+=rx(2,6) +send('system'); rx(1.5,5) +send('shell'); rx(1.5,5) +promptend=lambda b: b.rstrip().endswith(b'shell>') +for c in cmds: + send(c) + out=rx(idle,cap,stop=promptend) + print("\n$ %s\n%s"%(c,out)) +try: s.close() +except Exception: pass diff --git a/tools/gpon_env.py b/tools/gpon_env.py new file mode 100644 index 0000000..49b5a58 --- /dev/null +++ b/tools/gpon_env.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Shared connection settings for the telnet drivers in this directory. + +Resolved highest-priority-first from: the process environment, `../.env`, +`../example.env`, then built-in defaults. Keeps addresses and credentials out +of the scripts (and out of git) — see ../example.env. +""" +import os + +KEYS = ('GPON_HOST', 'GPON_PORT', 'GPON_USER', 'GPON_PASS') +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _parse(path): + vals = {} + try: + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + k, v = line.split('=', 1) + vals[k.strip()] = v.strip().strip('"').strip("'") + except OSError: + pass + return vals + + +_cfg = _parse(os.path.join(_ROOT, 'example.env')) +_cfg.update(_parse(os.path.join(_ROOT, '.env'))) +_cfg.update({k: v for k, v in os.environ.items() if k in KEYS}) + +HOST = _cfg.get('GPON_HOST') or '192.168.100.1' +PORT = int(_cfg.get('GPON_PORT') or 23) +USER = _cfg.get('GPON_USER') or 'root' +PASS = _cfg.get('GPON_PASS') or 'admin' diff --git a/tools/gpon_telnet.py b/tools/gpon_telnet.py new file mode 100755 index 0000000..a5990c8 --- /dev/null +++ b/tools/gpon_telnet.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Minimal scriptable telnet client for probing the GPON stick CLI. +Usage: gpon_telnet.py [--host H] [--port P] [--user U] [--pass P] [--wait S] [cmd ...] +Host/port/user/pass default to the .env settings (see ../example.env). +Each positional cmd is sent as one line after login; output captured per cmd. +Reads extra commands (one per line) from stdin if --stdin given. +""" +import socket, sys, time, re +import gpon_env + +args = sys.argv[1:] +HOST, PORT = gpon_env.HOST, gpon_env.PORT +USER, PASS, WAIT, USE_STDIN = gpon_env.USER, gpon_env.PASS, 2.5, False +cmds = [] +while args: + a = args.pop(0) + if a == '--host': HOST = args.pop(0) + elif a == '--port': PORT = int(args.pop(0)) + elif a == '--user': USER = args.pop(0) + elif a == '--pass': PASS = args.pop(0) + elif a == '--wait': WAIT = float(args.pop(0)) + elif a == '--stdin': USE_STDIN = True + else: cmds.append(a) +if USE_STDIN: + cmds += [l.rstrip('\n') for l in sys.stdin if l.strip() != ''] + +def strip_iac(data, outbuf): + res = bytearray(); i = 0 + while i < len(data): + b = data[i] + if b == 0xFF and i+1 < len(data): + cmd = data[i+1] + if cmd in (251,252,253,254) and i+2 < len(data): + opt = data[i+2] + if cmd == 253: outbuf.append(bytes([255,252,opt])) # DO->WONT + elif cmd == 251: outbuf.append(bytes([255,254,opt])) # WILL->DONT + i += 3; continue + elif cmd == 250: # SB..SE + j = data.find(b'\xff\xf0', i+2) + i = len(data) if j == -1 else j+2; continue + else: + i += 2; continue + res.append(b); i += 1 + return bytes(res) + +s = socket.create_connection((HOST, PORT), timeout=8) + +def rx(timeout): + s.settimeout(0.6); buf = bytearray(); end = time.time()+timeout + while time.time() < end: + try: + data = s.recv(4096) + if not data: break + ob = [] + buf.extend(strip_iac(data, ob)) + for o in ob: s.sendall(o) + except socket.timeout: + if buf: break + return buf.decode('latin-1','replace') + +out = rx(3.0) +if re.search(r'(ogin|sername)', out): + s.sendall(USER.encode()+b'\r\n'); out += rx(2.0) +if re.search(r'assword', out, re.I): + s.sendall(PASS.encode()+b'\r\n'); out += rx(3.0) +print("===== BANNER/LOGIN =====\n"+out) +for c in cmds: + s.sendall(c.encode()+b'\r\n'); o = rx(WAIT) + print("\n===== CMD: %r =====\n%s" % (c, o)) +try: s.close() +except Exception: pass diff --git a/tools/gpon_verify.py b/tools/gpon_verify.py new file mode 100755 index 0000000..81c1daf --- /dev/null +++ b/tools/gpon_verify.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Wait for the stick's telnet to cycle after a reboot, then run #ONT CLI reads to verify. +Usage: gpon_verify.py [--wait S] [--idle S] [--cap S] 'cmd1' 'cmd2' ...""" +import socket, sys, time, re +from gpon_env import HOST, PORT, USER, PASS +args=sys.argv[1:]; idle=5.0; cap=25.0; wait=140.0; cmds=[] +while args: + a=args.pop(0) + if a=='--idle': idle=float(args.pop(0)) + elif a=='--cap': cap=float(args.pop(0)) + elif a=='--wait': wait=float(args.pop(0)) + else: cmds.append(a) + +def strip_iac(data,outbuf): + res=bytearray(); i=0 + while iidle: break + if time.time()-start>cap: break + return buf.decode('latin-1','replace') +def send(x): s.sendall(x.encode()+b'\r\n') + +o=rx(3,10) +if re.search(r'(ogin|sername)',o): send(USER); o+=rx(2,6) +if re.search(r'assword',o,re.I): send(PASS); o+=rx(3,8) +promptend=lambda b: b.rstrip().endswith(b'>') +for c in cmds: + send(c); out=rx(idle,cap,stop=promptend) + print("\n>>> %s\n%s"%(c,out)) +try: s.close() +except Exception: pass