Metadata-Version: 2.4
Name: routeros-client
Version: 0.6.3
Summary: Client MikroTik RouterOS multi-transport : API binaire, SSH et REST sous une seule et même surface d'API.
Author: Jack Karten
License-Expression: MIT
Project-URL: Homepage, https://github.com/jackarten/routeros-client
Project-URL: Repository, https://github.com/jackarten/routeros-client
Project-URL: Changelog, https://github.com/jackarten/routeros-client/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/jackarten/routeros-client/issues
Keywords: mikrotik,routeros,api,ssh,rest,network,automation,netdevops,router
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Intended Audience :: Telecommunications Industry
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: paramiko>=3.0
Provides-Extra: ssh
Provides-Extra: dev
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Requires-Dist: vermin>=1.6; extra == "dev"
Dynamic: license-file

# routeros-client

[![PyPI](https://img.shields.io/pypi/v/routeros-client.svg)](https://pypi.org/project/routeros-client/)
[![Python](https://img.shields.io/pypi/pyversions/routeros-client.svg)](https://pypi.org/project/routeros-client/)
[![CI](https://github.com/jackarten/routeros-client/actions/workflows/ci.yml/badge.svg)](https://github.com/jackarten/routeros-client/actions/workflows/ci.yml)
[![Licence MIT](https://img.shields.io/badge/licence-MIT-green.svg)](https://github.com/jackarten/routeros-client/blob/main/LICENSE)

Client Python pour MikroTik RouterOS : **une seule installation, quatre transports**.

Transports interchangeables exposant **exactement la même surface d'API** — votre code métier ne
change pas quand vous changez de transport.

| Transport | Classe | Port par défaut |
| --- | --- | --- |
| **API binaire** *(défaut)* | `Api` | 8728 |
| API binaire sur TLS | `Api(use_ssl=True)` | 8729 |
| **SSH** | `SshApi` | 22 |
| REST / JSON *(RouterOS v7)* | `RestApi` | 80 / 443 |

Compatible RouterOS **v6.x et v7.x**, Python **3.9+**. Validé en conditions réelles sur
**RouterOS 7.23.3 (stable)**.

---

## Installation

```bash
pip install routeros-client
```

C'est tout : les quatre transports sont disponibles d'emblée. `paramiko`, requis par le transport
SSH, est installé automatiquement et chargé **paresseusement** — un programme qui n'utilise que
l'API binaire ne paie pas son coût de démarrage.

> `pip install "routeros-client[ssh]"` reste accepté, et est strictement équivalent : l'extra est
> conservé comme alias sans effet pour ne casser aucun script existant.

### Nom d'installation ≠ nom d'import

Le nom `routeros-api` étant déjà occupé sur PyPI par un autre projet, la distribution s'appelle
**`routeros-client`**. Les deux noms d'import fonctionnent :

```python
import routeros_client        # nom canonique, aligné sur la distribution
import routeros_api           # alias de compatibilité, pleinement supporté
```

Ce ne sont pas deux copies mais **le même objet module** : `isinstance`, attributs privés et
monkeypatching se comportent à l'identique dans les deux cas. Le code écrit pour les versions
précédentes continue donc de fonctionner sans la moindre modification.

📘 **Guide d'utilisation complet** : `GUIDE.md`.

---

## Démarrage rapide

```python
from routeros_api import connect

# Transport API binaire — le comportement par défaut
api = connect("192.168.88.1", "admin", "secret")

print(api.get_identity())                       # 'MikroTik'
for iface in api.print("/interface", proplist="name,type,running"):
    print(iface["name"], iface["type"], iface["running"])

api.close()
```

Avec fermeture garantie :

```python
from routeros_api import api_session

with api_session("192.168.88.1", "admin", "secret") as api:
    adresses = api.get_resource("/ip/address").find(disabled=False)
```

### Vue « menu » : `ResourceProxy`

```python
fw = api.get_resource("/ip/firewall/address-list")

fw.add(list="blocked", address="203.0.113.7", comment="abus")
fw.find(list="blocked")                          # liste de dicts
fw.find_one(address="203.0.113.7")               # un dict ou None
fw.remove_where(list="blocked")                  # suppression par filtre
```

### Idempotence

```python
# Crée si absent, met à jour sinon. Retourne (.id, created)
item_id, cree = api.ensure(
    "/ip/firewall/address-list",
    {"list": "blocked", "address": "203.0.113.7"},   # critère de recherche
    {"comment": "mis à jour"},                       # valeurs à appliquer
)
```

---

## Choisir un transport

Le **transport API binaire est celui par défaut** : le plus rapide, et le seul à savoir multiplexer
(pipelining, `listen()`, `cancel()`).

```python
from routeros_api import connect, ssh_session, auto_connect

api = connect(ip, "admin", pwd)                              # API binaire (défaut)
api = connect(ip, "admin", pwd, use_ssl=True)                # API sur TLS (8729)
ros = connect(ip, "admin", pwd, transport="ssh",             # SSH
              key_filename="~/.ssh/id_ed25519")

# Bascule automatique : prend l'API si elle répond, sinon SSH
ros = auto_connect(ip, "admin", pwd, order=("api", "ssh"))
```

Le code qui suit est **identique quel que soit le transport** :

```python
with ssh_session(ip, "admin", password=pwd) as ros:
    print(ros.get_identity())
    ros.add("/ip/address", address="10.0.0.1/24", interface="ether1")
    for a in ros.get_resource("/ip/address").find():
        print(a[".id"], a["address"])
```

### Quand utiliser SSH plutôt que l'API

* le service API est **désactivé** sur l'équipement, mais SSH est ouvert ;
* vous voulez un **export de configuration** : `/export` ne renvoie **rien** via l'API binaire sur
  RouterOS v7 (limite de RouterOS, pas de la bibliothèque), alors que `SshApi.export()` fonctionne ;
* vous devez lancer des commandes hors du périmètre de l'API (scripts, `/tool fetch`, sauvegardes) :
  `ros.run("/system script run mon-script")` ;
* vous voulez transférer des fichiers : `ros.sftp_get()` / `ros.sftp_put()`.

Fonctionnement interne : les commandes protocolaires sont traduites en ligne de commande RouterOS,
exécutées dans un canal `exec` (pas d'analyse d'invite), puis la sortie est re-parsée en
enregistrements **identiques à ceux de l'API**. Trois formats sont tentés, du plus fidèle au plus
tolérant : `:serialize to=json` (v7, `.id` inclus) → `as-value` (v6/v7) → `print terse`. Le mode
retenu est mémorisé après la première commande.

**Limites du SSH** : pas de multiplexage — `batch(pipeline=True)` retombe en séquentiel, `cancel()`
est sans objet, `listen()` est émulé par `print follow`. Compter ~2 à 5 ms de surcoût par commande.

---

## Performance

### Pipelining — le gain le plus important

Envoie N commandes en une seule trame et relit les réponses par tag : **N allers-retours réseau
deviennent 1**.

```python
with api.pipeline() as p:
    for ip_bloquee in blocklist:                 # 5 000 entrées
        p.add("/ip/firewall/address-list/add",
              "=list=blocked", f"=address={ip_bloquee}")
print(len(p.responses))

# ou directement sur les écritures en masse
api.bulk_add("/ip/firewall/address-list", items, pipeline=True)
```

Mesuré sur RouterOS 7.23.3 en LAN, 40 ajouts : **145 ms → 37 ms (×3,9)**. Le gain croît avec la
latence : sur un lien WAN à 20 ms, 200 commandes passent d'environ 4 s à 0,1 s.

> À ne pas utiliser pour des commandes qui coupent la session (`/system/reboot`) ni pour des
> séquences dont l'ordre d'exécution importe : RouterOS traite les commandes taguées concurremment.

### Autres leviers

```python
# Réduire le volume transféré : le routeur n'envoie que les champs demandés
api.print("/ip/dhcp-server/lease", proplist=".id,address,mac-address")

# Mémoire constante sur les très grosses tables (aucune matérialisation)
for bail in api.iter_print("/ip/dhcp-server/lease", proplist="address,mac-address"):
    traiter(bail)
```

`TCP_NODELAY` est activé par défaut (jusqu'à ~40 ms de latence évités par commande) et la lecture du
tampon est en O(n) au lieu de O(n²).

`paramiko` est importé **paresseusement**, à la première connexion SSH : `import routeros_client`
coûte 475 ms au lieu de 982 ms si vous n'utilisez que l'API — bien qu'il soit toujours installé.

---

## Points d'attention en production

### `api-ssl` (8729) sans certificat

Sans certificat configuré — le cas par défaut — RouterOS ne propose que des suites **anonymes**
(`ADH-AES256-SHA256`), que Python refuse : le handshake échoue. La bibliothèque les réactive
automatiquement quand `ssl_verify=False` :

```python
api = connect(ip, "admin", pwd, use_ssl=True, ssl_verify=False)   # fonctionne
```

Le chiffrement reste actif mais **le pair n'est pas authentifié**. Pour une vraie sécurité,
installez un certificat sur le routeur et gardez `ssl_verify=True`.

### Clé d'hôte SSH

`strict_host_key=True` par défaut : la clé du routeur doit être connue (`known_hosts`). Pour un
premier contact en laboratoire, `strict_host_key=False` désactive la vérification — un avertissement
est journalisé, et vous devenez vulnérable à une attaque active.

### Erreurs

```python
from routeros_api import RouterOSCommandError, RouterOSConnectionError, RouterOSAuthError

try:
    api.add("/ip/address", address="pas-une-ip")
except RouterOSCommandError as exc:
    print(exc, exc.category)

# Variante sans exception
resp = api.talk("/ip/address/print", raise_on_trap=False)
if not resp.ok:
    print(resp.error_message)
```

### Robustesse

```python
api = connect(ip, "admin", pwd,
              timeout=10,              # timeout socket
              auto_reconnect=True,     # reconnexion transparente
              enable_resilience=True,  # retry avec backoff sur erreur réseau
              rate_limit=20)           # 20 commandes/s maximum
```

> `enable_resilience` rejoue la commande après une coupure. Pour les commandes non idempotentes
> (`add`), préférez `ensure()`.

---

## Développement et tests

```bash
python -m venv .venv
.venv/bin/activate                       # Windows : .\.venv\Scripts\Activate.ps1
pip install -e ".[dev]"

# Hors-ligne — 105 tests, aucun routeur nécessaire
python -m unittest test_routeros_api -v

# Contre un routeur réel — 62 tests (ÉCRIT sur l'équipement : laboratoire uniquement)
ROS_HOST=192.168.88.1 ROS_USER=admin ROS_PASSWORD=secret python test_live_router.py
```

La campagne hors-ligne simule le protocole binaire (faux socket) et le transport SSH (faux backend) :
elle couvre l'encodage/décodage, les parseurs CLI, la traduction des commandes, le quoting
anti-injection, l'équivalence des deux noms d'import et chaque régression corrigée
(tests `test_bugN_…`).

La campagne réelle couvre les quatre transports, les écritures, `listen()`, `export()`, `ping_host()`,
le pipelining, les bascules de transport et la **cohérence des données entre API et SSH**. Tous les
objets créés sont supprimés en fin de campagne. Paramétrage via `.env.example`.

### Publication

Le workflow `.github/workflows/publish.yml` publie sur PyPI via **Trusted Publishing** (OIDC, aucun
token à stocker) lorsqu'une étiquette de version est poussée. Il refuse de publier si l'étiquette ne
correspond pas à `__version__`.

**Une seule fois**, déclarer l'éditeur de confiance sur
<https://pypi.org/manage/account/publishing/> (rubrique *Add a new pending publisher*) :

| Champ | Valeur |
| --- | --- |
| PyPI Project Name | `routeros-client` |
| Owner | `jackarten` |
| Repository name | `routeros-client` |
| Workflow name | `publish.yml` |
| Environment name | `pypi` |

**À chaque version** :

```bash
git tag v0.6.1 && git push origin v0.6.1
```

---

## Contenu du dépôt

| Fichier | Rôle |
| --- | --- |
| `routeros_client.py` | La bibliothèque — module autonome |
| `routeros_api.py` | Alias de compatibilité vers `routeros_client` |
| `GUIDE.md` | Guide d'utilisation complet, 20 chapitres |
| `test_routeros_api.py` | Tests hors-ligne (105) |
| `test_live_router.py` | Campagne contre un routeur réel (62) |
| `CHANGELOG.md` | 27 bugs corrigés, 11 optimisations, transport SSH — détail et justification |
| `pyproject.toml` | Packaging, configuration ruff |
| `requirements.txt` | Dépendance d'exécution (`paramiko`) |
| `.env.example` | Modèle de configuration des tests réels |

`routeros_api_v0.5.0.bak.py` conserve la version précédente ; il est exclu du dépôt par `.gitignore`.

---

## Migration depuis la v0.5.0

Le code existant fonctionne **sans modification** : classes, signatures et valeurs de retour sont
préservées, les ajouts sont strictement additifs.

Trois différences de comportement, toutes des corrections de bugs (détail dans `CHANGELOG.md`) :

1. `split_command()` retire de nouveau les guillemets englobants — `=comment="a b"` créait un
   commentaire contenant littéralement les guillemets. Ancien comportement :
   `RouterOSProtocol.KEEP_QUOTES = True`.
2. Un `!trap` est signalé après lecture du `!done` : l'exception est la même, mais le flux n'est plus
   désynchronisé pour toutes les commandes suivantes.
3. Les booléens Python sont convertis : `disabled=True` produit `=disabled=yes` et non
   `=disabled=True`, que RouterOS refusait.

Si vous utilisiez `RestApi.add()`, il était cassé en v0.5.0 (`POST` → HTTP 400) : il utilise de
nouveau `PUT`, le verbe de création de l'API REST RouterOS.

---

## Licence

MIT — voir le fichier `LICENSE`.
