Initial commit

This commit is contained in:
2026-07-24 20:26:37 +03:00
commit 515d053f59
9 changed files with 1003 additions and 0 deletions
+17
View File
@@ -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
+522
View File
@@ -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.<vlan>` 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=<ADMIN_HOST> 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 (<field> 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 `<field> 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.<WAN_VLAN> interface=sfp1 vlan-id=<WAN_VLAN> 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.<WAN_VLAN> 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.<WAN_VLAN> 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.<WAN_VLAN> \
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=<LEASED_IP> interface=sfp1.<WAN_VLAN>
```
### 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.<WAN_VLAN> 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=<FIBRE_GATEWAY> routing-table=via-fibre comment="pbr: fibre default"
/ip firewall nat add chain=srcnat out-interface=sfp1.<WAN_VLAN> action=masquerade comment="pbr: masq to fibre"
# keep RFC1918 on the main table, send everything else out the fibre
/routing rule add src-address=<HOST> dst-address=10.0.0.0/8 action=lookup table=main comment=pbr
/routing rule add src-address=<HOST> dst-address=172.16.0.0/12 action=lookup table=main comment=pbr
/routing rule add src-address=<HOST> dst-address=192.168.0.0/16 action=lookup table=main comment=pbr
/routing rule add src-address=<HOST> 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.<WAN_VLAN>`; 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=<ADMIN_HOST> \
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=<ADMIN_HOST>
```
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 <field> 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.
+64
View File
@@ -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 <PLACEHOLDERS>).
# ---------------------------------------------------------------------------
# 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
+97
View File
@@ -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 <mtd_index> <outfile> [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 i<len(data):
b=data[i]
if b==0xFF and i+1<len(data):
c=data[i+1]
if c in (251,252,253,254) and i+2<len(data):
o=data[i+2]
if c==253: outbuf.append(bytes([255,252,o]))
elif c==251: outbuf.append(bytes([255,254,o]))
i+=3; continue
elif c==250:
j=data.find(b'\xff\xf0', i+2); i=len(data) if j<0 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(idle, cap, stop=None):
s.settimeout(0.5); buf=bytearray(); last=time.time(); start=time.time()
while True:
try:
d=s.recv(16384)
if not d: break
ob=[]; buf.extend(strip_iac(d,ob))
for o in ob: s.sendall(o)
last=time.time()
if stop and stop(buf): break
except socket.timeout:
if time.time()-last>idle: 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 ***"))
+58
View File
@@ -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 i<len(data):
b=data[i]
if b==0xFF and i+1<len(data):
c=data[i+1]
if c in (251,252,253,254) and i+2<len(data):
o=data[i+2]
if c==253: outbuf.append(bytes([255,252,o]))
elif c==251: outbuf.append(bytes([255,254,o]))
i+=3; continue
elif c==250:
j=data.find(b'\xff\xf0',i+2); i=len(data) if j<0 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(idle,cap,stop=None):
s.settimeout(0.5); buf=bytearray(); last=time.time(); start=time.time()
while True:
try:
d=s.recv(16384)
if not d: break
ob=[]; buf.extend(strip_iac(d,ob))
for o in ob: s.sendall(o)
last=time.time()
if stop and stop(buf): break
except socket.timeout:
if time.time()-last>idle: 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
+58
View File
@@ -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 i<len(data):
b=data[i]
if b==0xFF and i+1<len(data):
c=data[i+1]
if c in (251,252,253,254) and i+2<len(data):
o=data[i+2]
if c==253: outbuf.append(bytes([255,252,o]))
elif c==251: outbuf.append(bytes([255,254,o]))
i+=3; continue
elif c==250:
j=data.find(b'\xff\xf0',i+2); i=len(data) if j<0 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(idle,cap,stop=None):
s.settimeout(0.5); buf=bytearray(); last=time.time(); start=time.time()
while True:
try:
d=s.recv(16384)
if not d: break
ob=[]; buf.extend(strip_iac(d,ob))
for o in ob: s.sendall(o)
last=time.time()
if stop and stop(buf): break
except socket.timeout:
if time.time()-last>idle: 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
+36
View File
@@ -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'
+71
View File
@@ -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
+80
View File
@@ -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 i<len(data):
b=data[i]
if b==0xFF and i+1<len(data):
c=data[i+1]
if c in (251,252,253,254) and i+2<len(data):
o=data[i+2]
if c==253: outbuf.append(bytes([255,252,o]))
elif c==251: outbuf.append(bytes([255,254,o]))
i+=3; continue
elif c==250:
j=data.find(b'\xff\xf0',i+2); i=len(data) if j<0 else j+2; continue
else: i+=2; continue
res.append(b); i+=1
return bytes(res)
def port_open():
try:
c=socket.create_connection((HOST,PORT),timeout=3); c.close(); return True
except (OSError,) : return False
t0=time.time()
# 1) give the reboot a moment to take the stick down
time.sleep(5)
# 2) wait for it to go DOWN (confirms the reboot began)
went_down=False
while time.time()-t0 < 30:
if not port_open(): went_down=True; break
time.sleep(2)
print("stick went DOWN: %s (t=%ds)"%(went_down, time.time()-t0))
# 3) wait for it to come back UP
came_up=False
while time.time()-t0 < wait:
if port_open(): came_up=True; break
time.sleep(3)
if not came_up:
print("STICK DID NOT COME BACK within %ds -- check MikroTik sfp1 / re-seat"%wait); sys.exit(1)
print("stick back UP (t=%ds); giving telnetd a few s to settle"%(time.time()-t0))
time.sleep(4)
s=socket.create_connection((HOST,PORT),timeout=8)
def rx(idle,cap,stop=None):
s.settimeout(0.5); buf=bytearray(); last=time.time(); start=time.time()
while True:
try:
d=s.recv(16384)
if not d: break
ob=[]; buf.extend(strip_iac(d,ob))
for o in ob: s.sendall(o)
last=time.time()
if stop and stop(buf): break
except socket.timeout:
if time.time()-last>idle: 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