routeros-client 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- routeros_client-0.6.0/.env.example +17 -0
- routeros_client-0.6.0/CHANGELOG.md +437 -0
- routeros_client-0.6.0/LICENSE +21 -0
- routeros_client-0.6.0/MANIFEST.in +17 -0
- routeros_client-0.6.0/PKG-INFO +365 -0
- routeros_client-0.6.0/README.md +328 -0
- routeros_client-0.6.0/pyproject.toml +91 -0
- routeros_client-0.6.0/requirements-core.txt +8 -0
- routeros_client-0.6.0/requirements.txt +15 -0
- routeros_client-0.6.0/routeros_api.py +4207 -0
- routeros_client-0.6.0/routeros_client.egg-info/PKG-INFO +365 -0
- routeros_client-0.6.0/routeros_client.egg-info/SOURCES.txt +16 -0
- routeros_client-0.6.0/routeros_client.egg-info/dependency_links.txt +1 -0
- routeros_client-0.6.0/routeros_client.egg-info/requires.txt +9 -0
- routeros_client-0.6.0/routeros_client.egg-info/top_level.txt +1 -0
- routeros_client-0.6.0/setup.cfg +4 -0
- routeros_client-0.6.0/test_live_router.py +485 -0
- routeros_client-0.6.0/test_routeros_api.py +930 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Configuration de la campagne de tests réels (test_live_router.py).
|
|
2
|
+
# Copiez ce fichier en .env et adaptez-le, ou exportez ces variables.
|
|
3
|
+
# .env est ignoré par git : n'y mettez que des identifiants de LABORATOIRE.
|
|
4
|
+
|
|
5
|
+
ROS_HOST=192.168.88.1
|
|
6
|
+
ROS_USER=admin
|
|
7
|
+
ROS_PASSWORD=change-me
|
|
8
|
+
|
|
9
|
+
# Ports RouterOS — à ne surcharger que si vos services n'écoutent pas
|
|
10
|
+
# sur les ports standards.
|
|
11
|
+
ROS_PORT_API=8728
|
|
12
|
+
ROS_PORT_API_SSL=8729
|
|
13
|
+
ROS_PORT_SSH=22
|
|
14
|
+
ROS_PORT_REST=80
|
|
15
|
+
|
|
16
|
+
# Nom de la liste d'adresses jetable créée puis supprimée par les tests.
|
|
17
|
+
ROS_TEST_LIST=rosapi-selftest
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# Changelog — routeros_api
|
|
2
|
+
|
|
3
|
+
## v0.6.0
|
|
4
|
+
|
|
5
|
+
Trois axes : **23 bugs corrigés**, **9 optimisations de performance**, **transport SSH ajouté**.
|
|
6
|
+
La compatibilité ascendante avec la v0.5.0 est préservée (voir la section dédiée en fin de document).
|
|
7
|
+
|
|
8
|
+
L'ancienne version est conservée telle quelle dans `routeros_api_v0.5.0.bak.py`.
|
|
9
|
+
|
|
10
|
+
### Validation
|
|
11
|
+
|
|
12
|
+
| Campagne | Commande | Résultat |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Hors-ligne (aucun routeur requis) | `python -m unittest test_routeros_api -v` | **90/90** |
|
|
15
|
+
| Hors-ligne, contre la **wheel installée** | idem depuis un répertoire neutre | **90/90** |
|
|
16
|
+
| Routeur réel | `python test_live_router.py` | **62/62** |
|
|
17
|
+
| Analyse statique | `ruff check routeros_api.py` | **0 finding** |
|
|
18
|
+
| Version Python minimale du code | `vermin routeros_api.py` | **3.7** (installation déclarée 3.9+, voir §5) |
|
|
19
|
+
| Métadonnées de distribution | `twine check --strict dist/*` | **PASSED** |
|
|
20
|
+
|
|
21
|
+
La campagne hors-ligne passe **avec et sans `paramiko` installé**, aussi bien depuis les sources que
|
|
22
|
+
depuis la wheel installée en site-packages.
|
|
23
|
+
|
|
24
|
+
Les tests concernés par un bug sont nommés `test_bugN_…`.
|
|
25
|
+
|
|
26
|
+
La campagne réelle a été exécutée contre un **RouterOS 7.23.3 (stable), CHR** sur
|
|
27
|
+
`192.168.117.132`, aux ports par défaut — API 8728, API-SSL 8729, SSH 22, REST 80 — et couvre les
|
|
28
|
+
quatre transports, les écritures (`add`/`set`/`remove`/`ensure`/`bulk_*`/pipelining), `listen()`,
|
|
29
|
+
`export()`, `ping_host()`, les bascules de transport, et la cohérence des données entre API et SSH.
|
|
30
|
+
Tous les objets créés pendant les tests sont supprimés en fin de campagne (vérifié).
|
|
31
|
+
**Les bugs 20, 21 et 22 ci-dessous n'ont été révélés que par cette campagne réelle** — aucun test
|
|
32
|
+
hors-ligne ne pouvait les détecter.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 1. Bugs corrigés
|
|
37
|
+
|
|
38
|
+
### BUG-1 — 🔴 Critique : désynchronisation permanente du flux après une erreur
|
|
39
|
+
|
|
40
|
+
`_read_response()` levait `RouterOSCommandError` **dès la réception du `!trap`** quand aucun tag
|
|
41
|
+
n'était attendu — c'est-à-dire pour tous les appels de `cmd()`, `talk()`, `print()`, `add()`, `set()`…
|
|
42
|
+
puisque `use_tag` vaut `False` par défaut.
|
|
43
|
+
|
|
44
|
+
Le protocole termine **toujours** une commande par un `!done`, y compris après un `!trap`. Ce `!done`
|
|
45
|
+
restait donc dans le tampon et était lu par la commande **suivante** comme si c'était sa réponse.
|
|
46
|
+
|
|
47
|
+
Conséquence : après la moindre erreur de syntaxe ou de permission, toutes les commandes suivantes
|
|
48
|
+
étaient décalées d'une réponse — `print()` renvoyait des listes vides, `add()` semblait réussir sans
|
|
49
|
+
rien créer. Le tout sans aucune erreur visible.
|
|
50
|
+
|
|
51
|
+
**Correctif** : le `!trap` est mémorisé, la lecture continue jusqu'au `!done`, puis l'exception est levée.
|
|
52
|
+
Un délai de garde (`trap_drain_timeout`, 5 s) évite un blocage si un routeur n'envoie pas le `!done`.
|
|
53
|
+
*(test `test_bug1_trap_keeps_stream_in_sync`)*
|
|
54
|
+
|
|
55
|
+
### BUG-2 — 🔴 `cmd_timeout` laissait le socket définitivement en mode non bloquant
|
|
56
|
+
|
|
57
|
+
La restauration était conditionnée par `if prev_timeout is not None`. Or `gettimeout()` renvoie
|
|
58
|
+
précisément `None` pour un socket bloquant — le cas par défaut. Après un seul appel avec
|
|
59
|
+
`cmd_timeout=…`, le socket gardait ce timeout **pour toute la session** : les commandes longues
|
|
60
|
+
(`/export`, gros `print`) échouaient ensuite en timeout sans raison apparente.
|
|
61
|
+
|
|
62
|
+
**Correctif** : sentinelle `_UNSET` pour distinguer « non modifié » de « était None ».
|
|
63
|
+
*(test `test_bug2_timeout_restored_on_blocking_socket`)*
|
|
64
|
+
|
|
65
|
+
### BUG-3 — 🔴 La reconnexion automatique détruisait la file de tâches
|
|
66
|
+
|
|
67
|
+
`_ensure_connected()` appelait `self.close()`, qui exécute `queue.shutdown()` puis `self.queue = None`.
|
|
68
|
+
Après une reconnexion réussie : `api.queue.submit(...)` → `AttributeError: 'NoneType'`.
|
|
69
|
+
Pire, si la reconnexion partait d'un worker de la file, `close()` faisait joindre le thread courant
|
|
70
|
+
à lui-même → `RuntimeError: cannot join current thread`.
|
|
71
|
+
|
|
72
|
+
**Correctif** : nouveau `_reset_connection()` qui ne ferme que le socket ; `ApiQueue.shutdown()`
|
|
73
|
+
ignore le thread courant.
|
|
74
|
+
|
|
75
|
+
### BUG-4 — 🔴 Réponses taguées d'une autre commande interprétées comme siennes
|
|
76
|
+
|
|
77
|
+
Le filtre `if expected_tag and phrase_tag and phrase_tag != expected_tag` ne s'appliquait que
|
|
78
|
+
lorsqu'un tag était attendu. Sans tag, une phrase taguée (reliquat d'un `listen()` annulé, d'un
|
|
79
|
+
`/cancel` tardif) était intégrée aux enregistrements de la commande en cours.
|
|
80
|
+
|
|
81
|
+
**Correctif** : toute phrase portant un tag différent de celui attendu est écartée vers
|
|
82
|
+
`_ResponseRouter`. `!fatal` reste traité en priorité, quel que soit son tag.
|
|
83
|
+
*(test `test_bug4_foreign_tag_is_not_consumed_as_own`)*
|
|
84
|
+
|
|
85
|
+
### BUG-4b — 🔴 `/cancel` hors verrou et jamais consommé
|
|
86
|
+
|
|
87
|
+
`cancel()` écrivait sur le socket **sans** `_socket_lock` : la trame pouvait s'entrelacer avec celle
|
|
88
|
+
d'un autre thread. De plus, `/cancel` n'était pas tagué : le `!done` qu'il provoque était attribué à
|
|
89
|
+
la commande suivante (même symptôme que BUG-1).
|
|
90
|
+
|
|
91
|
+
**Correctif** : envoi sous verrou, `/cancel` porte son propre tag, sa réponse est identifiée et écartée.
|
|
92
|
+
|
|
93
|
+
### BUG-5 — 🔴 `ApiQueue.shutdown()` abandonnait les futures en attente
|
|
94
|
+
|
|
95
|
+
Les tâches encore en file n'étaient ni exécutées, ni annulées, ni marquées `task_done()` :
|
|
96
|
+
un `future.result()` bloquait **indéfiniment**, et `ApiQueue.join()` ne revenait jamais.
|
|
97
|
+
|
|
98
|
+
**Correctif** : purge de la file au shutdown, chaque future est annulée et chaque tâche marquée terminée.
|
|
99
|
+
*(test `test_bug5_shutdown_resolves_pending_futures`)*
|
|
100
|
+
|
|
101
|
+
### BUG-6 — 🟠 Fuite mémoire du routeur de réponses
|
|
102
|
+
|
|
103
|
+
`_ResponseRouter.dispatch()` empilait les phrases dans des `Queue` que rien ne consommait, et
|
|
104
|
+
`release()` n'était jamais appelé. Sur un service de longue durée utilisant des tags, la mémoire
|
|
105
|
+
croissait sans borne.
|
|
106
|
+
|
|
107
|
+
**Correctif** : routeur borné (1000 phrases, éviction des plus anciennes avec log), `release()` appelé
|
|
108
|
+
en fin de chaque commande taguée, `clear()` à la déconnexion.
|
|
109
|
+
|
|
110
|
+
### BUG-7 — 🟠 `talk()` modifiait la liste de l'appelant
|
|
111
|
+
|
|
112
|
+
`_execute_talk()` faisait `words = msg` (sans copie) puis `words.append(".tag=…")`. La liste passée par
|
|
113
|
+
l'appelant était donc modifiée, et un retry (`enable_resilience`) ajoutait un **second** `.tag=` à la
|
|
114
|
+
même commande — que RouterOS rejette.
|
|
115
|
+
|
|
116
|
+
**Correctif** : la liste est copiée dès qu'un tag doit être ajouté.
|
|
117
|
+
*(test `test_bug7_caller_list_is_not_mutated`)*
|
|
118
|
+
|
|
119
|
+
### BUG-8 — 🟠 Régression : les guillemets étaient stockés dans les valeurs
|
|
120
|
+
|
|
121
|
+
Le « BUG-9 FIX » de la v0.5.0 conservait les guillemets englobants dans le mot encodé.
|
|
122
|
+
Le protocole binaire délimitant les mots par **longueur**, aucun quoting n'est nécessaire :
|
|
123
|
+
`=comment="hello world"` créait un commentaire contenant littéralement les guillemets.
|
|
124
|
+
|
|
125
|
+
**Correctif** : retour au comportement correct (guillemets retirés), plus le support de `\"`.
|
|
126
|
+
Ancien comportement disponible via `RouterOSProtocol.KEEP_QUOTES = True`.
|
|
127
|
+
*(tests `test_split_command_*`)*
|
|
128
|
+
|
|
129
|
+
### BUG-9 — 🟡 `APIResponse.error_message` documenté mais jamais renseigné
|
|
130
|
+
|
|
131
|
+
Le champ était annoncé dans le code comme « peuplé par `_read_response()` » ; il valait toujours `None`.
|
|
132
|
+
|
|
133
|
+
**Correctif** : nouveau paramètre `raise_on_trap=False` sur `talk()` / `_read_response()` qui renvoie
|
|
134
|
+
la réponse avec `error_message` et `error_category` renseignés. Ajout de `.ok` et `.raise_for_error()`.
|
|
135
|
+
|
|
136
|
+
### BUG-10 — 🟠 `listen(timeout=…)` sans effet
|
|
137
|
+
|
|
138
|
+
L'échéance était vérifiée **avant** un `read_sentence()` bloquant. Sur une connexion sans timeout socket
|
|
139
|
+
(le défaut), l'appel restait bloqué pour toujours et le paramètre `timeout` n'avait aucun effet.
|
|
140
|
+
|
|
141
|
+
**Correctif** : lecture par tranches d'une seconde, échéance réévaluée à chaque tranche.
|
|
142
|
+
*(test `test_bug10_listen_timeout_is_enforced`)*
|
|
143
|
+
|
|
144
|
+
### BUG-11 — 🟡 `ApiQueue.submit(timeout=…)` silencieusement ignoré
|
|
145
|
+
|
|
146
|
+
Le paramètre était accepté puis jamais utilisé.
|
|
147
|
+
**Correctif** : transmis comme `cmd_timeout` à `talk()`. *(test `test_bug11_submit_timeout_is_applied`)*
|
|
148
|
+
|
|
149
|
+
### BUG-12 — 🟠 Filtres `id=` sans effet dans `get_filtered()` et `ResourceProxy.get()`
|
|
150
|
+
|
|
151
|
+
Seul `get_proplist()` normalisait les clés système. `proxy.find(id="*1")` envoyait `?id=*1`, que
|
|
152
|
+
RouterOS ignore : la requête retournait **toute la table** au lieu d'une entrée — silencieusement.
|
|
153
|
+
Une valeur `None` produisait par ailleurs le filtre absurde `?key=None`.
|
|
154
|
+
|
|
155
|
+
**Correctif** : normalisation centralisée dans `_query_words()` ; `None` produit `?key` (test
|
|
156
|
+
d'existence) ; support des opérateurs `?>`, `?<`. *(test `test_bug12_query_words_normalize_id`)*
|
|
157
|
+
|
|
158
|
+
### BUG-13 — 🟡 `ping_host()` : intervalle tronqué et latences non analysées
|
|
159
|
+
|
|
160
|
+
* `interval=1.5` produisait `interval=1s` (`ms // 1000`) ;
|
|
161
|
+
* `float("12ms490us".replace("ms",""))` lève `ValueError`, silencieusement avalée : `min/avg/max_ms`
|
|
162
|
+
restaient à 0 sur RouterOS v7, qui renvoie systématiquement ce format.
|
|
163
|
+
|
|
164
|
+
**Correctif** : `parse_duration_ms()` gère `12ms490us`, `2s500ms`, `1w2d3h`, `00:00:01.5` ;
|
|
165
|
+
l'intervalle sub-seconde est émis en millisecondes. *(test `test_bug13_ping_parses_sub_ms_and_interval`)*
|
|
166
|
+
|
|
167
|
+
### BUG-14 — 🟡 `get_multi()` fragile
|
|
168
|
+
|
|
169
|
+
Indexation `responses[i]` sans garde (IndexError possible) et test `if responses[i]` faux pour une
|
|
170
|
+
table vide, `APIResponse.__len__` valant alors 0.
|
|
171
|
+
**Correctif** : correspondance par index sécurisée et test de type explicite.
|
|
172
|
+
|
|
173
|
+
### BUG-15 — 🟡 Paramètre `encoding` accepté puis ignoré
|
|
174
|
+
|
|
175
|
+
`Api(encoding="latin-1")` n'avait aucun effet : `encode_word()` codait en dur `utf-8`.
|
|
176
|
+
**Correctif** : l'encodage est propagé jusqu'à l'encodage/décodage des mots.
|
|
177
|
+
|
|
178
|
+
### BUG-16 — 🟡 Octet d'en-tête réservé traité comme une fin de phrase
|
|
179
|
+
|
|
180
|
+
`decode_word_length()` renvoyait `(0, 1)` pour `0xF1..0xFF`, ce qui simulait un mot vide — donc une
|
|
181
|
+
fin de phrase — et masquait une désynchronisation du flux.
|
|
182
|
+
**Correctif** : `RouterOSProtocolError` explicite.
|
|
183
|
+
|
|
184
|
+
### BUG-17 — 🟡 `AttributeError` au lieu d'une erreur typée
|
|
185
|
+
|
|
186
|
+
`_read_response()` et `export()` déréférençaient `self.sock` sans garde : après un `!fatal` ou un
|
|
187
|
+
`close()` concurrent, l'appelant recevait `AttributeError: 'NoneType' object has no attribute
|
|
188
|
+
'read_sentence'` au lieu de `RouterOSConnectionError`.
|
|
189
|
+
|
|
190
|
+
### BUG-18 — 🟡 Erreur d'authentification masquée en mode AUTO
|
|
191
|
+
|
|
192
|
+
Sur RouterOS ≥ 6.45 (authentification legacy supprimée), un mot de passe erroné produisait
|
|
193
|
+
« Pas de challenge reçu » — message trompeur, la vraie cause étant perdue.
|
|
194
|
+
**Correctif** : les deux erreurs (plaintext + legacy) sont rapportées et chaînées.
|
|
195
|
+
|
|
196
|
+
### BUG-19 — 🟡 `RestApi` : authentification non préemptive
|
|
197
|
+
|
|
198
|
+
Avec le seul `HTTPBasicAuthHandler`, **chaque** requête coûtait deux allers-retours (401 puis rejeu),
|
|
199
|
+
et échouait si le routeur ne renvoyait pas un `WWW-Authenticate` exploitable.
|
|
200
|
+
**Correctif** : en-tête `Authorization: Basic` envoyé d'emblée (`preemptive_auth=True`), 401/403
|
|
201
|
+
remontés en `RouterOSAuthError`, respect de `Retry-After`, `close()` robuste.
|
|
202
|
+
|
|
203
|
+
### BUG-20 — 🔴 `api-ssl` (8729) inutilisable : handshake TLS systématiquement refusé
|
|
204
|
+
|
|
205
|
+
*Découvert en conditions réelles.* Sans certificat configuré — la situation par défaut — le service
|
|
206
|
+
`api-ssl` de RouterOS ne propose **que des suites anonymes** (`ADH-AES256-SHA256`). Le contexte SSL par
|
|
207
|
+
défaut de Python les refuse : toute connexion `use_ssl=True` échouait sur
|
|
208
|
+
`SSLV3_ALERT_HANDSHAKE_FAILURE`, y compris avec `ssl_verify=False`. Le transport TLS de la v0.5.0 ne
|
|
209
|
+
pouvait donc pas fonctionner contre une configuration RouterOS standard.
|
|
210
|
+
|
|
211
|
+
**Correctif** : quand `ssl_verify=False` (l'appelant a déjà renoncé à authentifier le pair), le
|
|
212
|
+
contexte réactive les suites anonymes en essayant `ALL:@SECLEVEL=0`, puis `ADH:@SECLEVEL=0`, puis
|
|
213
|
+
`ALL:aNULL`. Désactivable par `ssl_allow_anonymous=False`.
|
|
214
|
+
Vérifié sur ROS 7.23.3 : négocie `ADH-AES256-SHA256 / TLSv1.2`.
|
|
215
|
+
|
|
216
|
+
### BUG-21 — 🟠 `RestApi.add()` : mauvais verbe HTTP (régression v0.5.0)
|
|
217
|
+
|
|
218
|
+
*Découvert en conditions réelles.* Le « BUG-10 FIX » de la v0.5.0 avait remplacé `PUT` par `POST` au
|
|
219
|
+
nom de la sémantique REST habituelle. L'API REST de RouterOS ne suit pas cette convention :
|
|
220
|
+
|
|
221
|
+
| Verbe | Effet réel côté RouterOS |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| `PUT /rest/<menu>` | **crée** — HTTP 201 + objet créé |
|
|
224
|
+
| `POST /rest/<menu>` | HTTP 400 `{"detail":"no such command"}` |
|
|
225
|
+
| `POST /rest/<menu>/<commande>` | invoque une commande (`/print`, `/monitor`…) |
|
|
226
|
+
| `PATCH /rest/<menu>/<id>` | modifie |
|
|
227
|
+
| `DELETE /rest/<menu>/<id>` | supprime |
|
|
228
|
+
|
|
229
|
+
`add()` était donc **cassé** en v0.5.0 : toute création via REST échouait en HTTP 400.
|
|
230
|
+
|
|
231
|
+
**Correctif** : retour à `PUT`, plus une méthode `command()` pour l'invocation de commandes.
|
|
232
|
+
|
|
233
|
+
### BUG-22 — 🟠 SSH : options placées avant les paramètres
|
|
234
|
+
|
|
235
|
+
*Découvert en conditions réelles.* `_CliCommand.render()` émettait les options avant les paramètres,
|
|
236
|
+
produisant `/ping as-value address=127.0.0.1`. Or `/ping` prend l'hôte en **argument positionnel** :
|
|
237
|
+
RouterOS tentait de joindre un hôte nommé « as-value », le mode `as-value` échouait, et `ping_host()`
|
|
238
|
+
retombait silencieusement sur l'analyse du tableau texte — d'où des latences perdues (`avg_ms = 0.0`
|
|
239
|
+
alors que le routeur répondait en 0,077 ms).
|
|
240
|
+
|
|
241
|
+
**Correctif** : ordre `commande → positionnels → paramètres → options → where`, valide pour les deux
|
|
242
|
+
familles de commandes. *(test `test_bug22_options_come_after_parameters`)*
|
|
243
|
+
|
|
244
|
+
### BUG-23 — 🟡 SSH/OpenSSH : `timeout` ignoré sur les flux
|
|
245
|
+
|
|
246
|
+
*Découvert par l'analyse statique finale (`ruff ARG002`).* `_OpenSshBackend.stream()` acceptait un
|
|
247
|
+
paramètre `timeout` puis ne s'en servait jamais : un `print follow` lancé via le client OpenSSH
|
|
248
|
+
tournait indéfiniment même lorsque l'appelant avait fixé une échéance. Même classe de défaut que
|
|
249
|
+
BUG-11.
|
|
250
|
+
|
|
251
|
+
**Correctif** : le flux s'interrompt une fois l'échéance atteinte, et le processus est terminé dans
|
|
252
|
+
le `finally`.
|
|
253
|
+
|
|
254
|
+
### Limite RouterOS documentée (et non un bug de la bibliothèque)
|
|
255
|
+
|
|
256
|
+
Sur RouterOS 7.23.3, **`/export` ne renvoie rien via le protocole API binaire** : ni `!re`, ni `=ret=`,
|
|
257
|
+
ni erreur. `Api.export()` retourne donc une chaîne vide quoi que fasse la bibliothèque — un
|
|
258
|
+
avertissement est désormais journalisé pour éviter le diagnostic à l'aveugle. `SshApi.export()`
|
|
259
|
+
fonctionne pleinement : c'est la voie à utiliser pour sauvegarder une configuration.
|
|
260
|
+
|
|
261
|
+
### Corrections mineures complémentaires
|
|
262
|
+
|
|
263
|
+
* `export()` : test `"=message=" in word` remplacé par une analyse correcte ; l'export passe désormais
|
|
264
|
+
par le chemin de lecture commun (tag, verrou, gestion du `!trap`).
|
|
265
|
+
* `api_session()` : `retry` négatif ne peut plus produire `yield None`.
|
|
266
|
+
* `__del__` : ne relance plus de threads pendant l'arrêt de l'interpréteur.
|
|
267
|
+
* `close()` : idempotent, remet `connected`/`logged_in` à `False` même sans socket.
|
|
268
|
+
* `_send_sentence()` : masque aussi `=response=` (empreinte MD5) dans les logs `verbose`.
|
|
269
|
+
* `cmd()` / `print()` : `cmd_timeout` (et `on_record`) sont devenus des arguments **nommés
|
|
270
|
+
exclusifs**. Ils partaient auparavant dans `**kwargs` et étaient envoyés au routeur comme des
|
|
271
|
+
propriétés — `api.print("/x", cmd_timeout=5)` provoquait `unknown parameter cmd_timeout`.
|
|
272
|
+
`add()`, `set()`, `remove()`, `enable()` et `disable()` en héritent.
|
|
273
|
+
* `auto_connect()` : nouveau `transport_kwargs={"api": {...}, "ssh": {...}}`. Un `port=` global
|
|
274
|
+
s'appliquait à tous les transports, ce qui rendait la bascule API→SSH impossible dès qu'un port
|
|
275
|
+
non standard était précisé.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## 2. Performances
|
|
280
|
+
|
|
281
|
+
| # | Optimisation | Gain |
|
|
282
|
+
| --- | --- | --- |
|
|
283
|
+
| P1 | Tampon de réception à curseur + compactage amorti (`del buffer[:n]` était exécuté à **chaque mot**) | O(n²) → O(n) : ~40× plus rapide sur une réponse de 10 000 enregistrements |
|
|
284
|
+
| P2 | `TCP_NODELAY` activé | supprime jusqu'à ~40 ms de latence Nagle/ACK différé **par commande** |
|
|
285
|
+
| P3 | Décodage de l'en-tête sur place (plus de double analyse ni de slice intermédiaire) | ~25 % sur la boucle de lecture |
|
|
286
|
+
| P4 | `partition()` au lieu de `split("=", 1)` + liaisons locales dans les boucles chaudes | ~10 % sur le parsing des enregistrements |
|
|
287
|
+
| P5 | **Pipelining** : `batch(pipeline=True)` envoie N commandes en une trame et lit les réponses par tag | N allers-retours → 1 (200 commandes à 20 ms de latence : ~4 s → ~0,1 s) |
|
|
288
|
+
| P6 | `ApiQueue` créée à la demande (`lazy_queue=True`) | plus de thread inutile par instance `Api` |
|
|
289
|
+
| P7 | `SO_KEEPALIVE` + `set_keepalive` SSH | détection des connexions mortes |
|
|
290
|
+
| P8 | REST : authentification préemptive | 2 requêtes HTTP → 1 |
|
|
291
|
+
| P9 | `paramiko` importé **paresseusement** (à la première connexion SSH) | `import routeros_api` : **982 ms → 475 ms** quand paramiko est installé |
|
|
292
|
+
|
|
293
|
+
À cela s'ajoutent `iter_print()` (lecture au fil de l'eau, mémoire constante) et le multiplexage
|
|
294
|
+
d'une seule connexion SSH pour toutes les commandes (backend paramiko).
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 3. Transport SSH (nouveau)
|
|
299
|
+
|
|
300
|
+
`SshApi` expose **exactement la même surface** que `Api` — `talk / cmd / print / add / set / remove /
|
|
301
|
+
enable / disable / batch / bulk_* / get_resource / ping_host / export / listen` — si bien que le code
|
|
302
|
+
métier existant fonctionne sans modification :
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
from routeros_api import connect, ssh_session
|
|
306
|
+
|
|
307
|
+
# API binaire (inchangé)
|
|
308
|
+
api = connect("192.168.88.1", "admin", "secret")
|
|
309
|
+
|
|
310
|
+
# SSH — même code métier ensuite
|
|
311
|
+
ros = connect("192.168.88.1", "admin", transport="ssh",
|
|
312
|
+
key_filename="~/.ssh/id_ed25519")
|
|
313
|
+
|
|
314
|
+
with ssh_session("192.168.88.1", "admin", password="secret") as ros:
|
|
315
|
+
for addr in ros.get_resource("/ip/address").find(disabled=False):
|
|
316
|
+
print(addr["address"])
|
|
317
|
+
|
|
318
|
+
# Bascule automatique API → SSH selon ce qui répond
|
|
319
|
+
ros = auto_connect(ip, "admin", pwd, order=("api", "ssh"))
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Fonctionnement.** Les commandes protocolaires (`"/ip/address/print"`, `"=address=…"`,
|
|
323
|
+
`"?disabled=false"`) sont traduites en ligne de commande RouterOS par `RouterOSCli`, exécutées dans un
|
|
324
|
+
canal `exec` (aucune analyse d'invite, code de retour disponible), puis la sortie est re-parsée en
|
|
325
|
+
enregistrements identiques à ceux de l'API. Trois formats sont essayés, du plus fidèle au plus tolérant :
|
|
326
|
+
|
|
327
|
+
1. `:put [:serialize to=json [<cmd> as-value]]` — RouterOS v7, restitue les mêmes champs que l'API, `.id` compris ;
|
|
328
|
+
2. `:put [<cmd> as-value]` — RouterOS v6 et v7 ;
|
|
329
|
+
3. `<cmd> without-paging terse` — repli universel.
|
|
330
|
+
|
|
331
|
+
Le mode retenu est mémorisé après la première commande.
|
|
332
|
+
|
|
333
|
+
**Dépendances.** `paramiko` est **optionnel** : s'il est installé, mot de passe / clé / agent sont
|
|
334
|
+
supportés. Sinon, le module bascule sur le client `ssh` du système (clé ou agent uniquement ; un mot de
|
|
335
|
+
passe lève une erreur explicite indiquant les options).
|
|
336
|
+
|
|
337
|
+
> **À installer si vous utilisez SSH avec un mot de passe** : `pip install paramiko`.
|
|
338
|
+
> Sans lui, `SshApi(..., password="…")` lève `RouterOSSSHError` avec le message d'aide correspondant.
|
|
339
|
+
> La campagne réelle a été menée avec paramiko 5.0.0.
|
|
340
|
+
|
|
341
|
+
**Sécurité.** Toute valeur sortant d'un jeu de caractères sûr est quotée et échappée (`\`, `"`, `$`,
|
|
342
|
+
sauts de ligne) et les segments de chemin sont validés — sans quoi une valeur contenant `;` ou un saut
|
|
343
|
+
de ligne permettrait d'injecter une commande dans la console. Vérification de la clé d'hôte activée par
|
|
344
|
+
défaut (`strict_host_key=True`).
|
|
345
|
+
|
|
346
|
+
**Limites.** Pas de multiplexage par tag : `batch(pipeline=True)` retombe en séquentiel, `cancel()` est
|
|
347
|
+
sans objet, `listen()` est émulé via `print follow`. Compter ~2 à 5 ms de surcoût par commande.
|
|
348
|
+
|
|
349
|
+
Extras : `run()` / `run_script()` (passe-plat CLI brut, pour ce que l'API ne sait pas faire),
|
|
350
|
+
`sftp_get()` / `sftp_put()` (backend paramiko).
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## 4. Autres nouveautés
|
|
355
|
+
|
|
356
|
+
* `ensure(path, match, values)` — création/mise à jour idempotente, retourne `(.id, created)`.
|
|
357
|
+
* `find_id()`, `find_ids()`, `remove_where()`, `is_alive()`.
|
|
358
|
+
* `iter_print()` — générateur à mémoire constante sur les grosses tables.
|
|
359
|
+
* `Api.pipeline()` — gestionnaire de contexte autour du pipelining.
|
|
360
|
+
* `APIResponse` : `.ok`, `.first()`, `.values()`, `.ids()`, `.typed()`, `.raise_for_error()`.
|
|
361
|
+
* `to_python()`, `parse_duration_ms()` — conversion de valeurs RouterOS (opt-in).
|
|
362
|
+
* `_format_value()` : `True` → `yes`, `None` → `""`, `["a","b"]` → `"a,b"` (la v0.5.0 envoyait `True`).
|
|
363
|
+
* `cmd(..., _params={...})` — échappatoire pour les propriétés dont le nom entre en collision avec un
|
|
364
|
+
paramètre Python (`path`, `proplist`).
|
|
365
|
+
* `auto_connect()`, `ssh_connect()`, `rest_connect()`, `ssh_session()`, enums `Transport` / `SshBackend`.
|
|
366
|
+
* `RestApi.query()` — filtrage côté serveur.
|
|
367
|
+
* Retry avec jitter, `Retry-After` respecté côté REST.
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## 5. Distribution / PyPI
|
|
372
|
+
|
|
373
|
+
Le projet est désormais publiable. Point à connaître avant toute installation :
|
|
374
|
+
|
|
375
|
+
> **Le nom de distribution diffère du nom d'import.**
|
|
376
|
+
> `pip install routeros-client` → `import routeros_api`
|
|
377
|
+
|
|
378
|
+
`routeros-api` est déjà occupé sur PyPI par un autre projet (v0.21.0, « Python API to RouterBoard
|
|
379
|
+
devices produced by MikroTik »). PyPI normalisant les noms, ses variantes `routeros_api` et
|
|
380
|
+
`RouterOS-api` désignent ce même projet : aucune n'était disponible. Le **nom d'import reste
|
|
381
|
+
`routeros_api`** afin que les applications existantes continuent de fonctionner sans modification.
|
|
382
|
+
|
|
383
|
+
*Conséquence à surveiller* : ce paquet et le `routeros-api` de PyPI installent tous deux un
|
|
384
|
+
`routeros_api` importable. Ne les installez pas dans le même environnement.
|
|
385
|
+
|
|
386
|
+
Fichiers ajoutés :
|
|
387
|
+
|
|
388
|
+
| Fichier | Rôle |
|
|
389
|
+
| --- | --- |
|
|
390
|
+
| `pyproject.toml` | Métadonnées PEP 621, `dependencies = []`, extras `[ssh]` et `[dev]`, config ruff |
|
|
391
|
+
| `LICENSE` | MIT |
|
|
392
|
+
| `MANIFEST.in` | Contenu de l'archive source |
|
|
393
|
+
| `.github/workflows/ci.yml` | Tests sur 6 combinaisons OS × Python (3.9→3.13), **avec et sans paramiko**, ruff, vermin, construction et validation des artefacts |
|
|
394
|
+
| `.github/workflows/publish.yml` | Publication PyPI sur étiquette `v*` via Trusted Publishing (OIDC, aucun token stocké), avec garde-fou de cohérence étiquette ↔ `__version__` |
|
|
395
|
+
|
|
396
|
+
Dépôt cible : <https://github.com/jackarten/routeros-client> — renseigné dans `[project.urls]` et
|
|
397
|
+
dans le workflow de publication.
|
|
398
|
+
|
|
399
|
+
### Plancher Python : 3.9
|
|
400
|
+
|
|
401
|
+
Les métadonnées de licence suivent la **PEP 639** (`license = "MIT"` en expression SPDX +
|
|
402
|
+
`license-files`), qui exige **setuptools ≥ 77**. Or setuptools a abandonné Python 3.8 en v76 : sur
|
|
403
|
+
3.8, la version la plus récente disponible est 75.x, qui ne connaît que l'ancienne forme
|
|
404
|
+
`license = { text = "MIT" }` et rejette la chaîne SPDX avec
|
|
405
|
+
« `project.license` must be valid exactly by one definition ». La construction du paquet y est donc
|
|
406
|
+
impossible.
|
|
407
|
+
|
|
408
|
+
Python 3.8 étant en fin de vie depuis octobre 2024, `requires-python` est fixé à **`>=3.9`** plutôt
|
|
409
|
+
que de revenir à des métadonnées dépréciées. Le **code**, lui, reste compatible 3.7+ (vérifié par
|
|
410
|
+
vermin) : pour couvrir 3.8, il suffit de repasser `license` sous la forme `{ text = "MIT" }`,
|
|
411
|
+
d'ajouter le classifieur `License :: OSI Approved :: MIT License` et d'abaisser
|
|
412
|
+
`requires = ["setuptools>=61"]`. La marche à suivre est consignée dans `pyproject.toml`.
|
|
413
|
+
|
|
414
|
+
Reste à faire avant la première publication : créer le dépôt distant et y pousser le code, puis
|
|
415
|
+
déclarer l'éditeur de confiance sur <https://pypi.org/manage/account/publishing/>
|
|
416
|
+
(*Add a new pending publisher* : projet `routeros-client`, propriétaire `jackarten`, dépôt
|
|
417
|
+
`routeros-client`, workflow `publish.yml`, environment `pypi`). Aucun token n'est à stocker.
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## 6. Compatibilité ascendante
|
|
422
|
+
|
|
423
|
+
Vérifiée par les tests `TestFactories` (surface publique, signature de `Api.__init__`) et par
|
|
424
|
+
`smoke.py` : tous les symboles, méthodes et paramètres nommés de la v0.5.0 sont présents et
|
|
425
|
+
conservent leur sémantique. `api.queue` reste un attribut assignable (`api.queue = None` fonctionne),
|
|
426
|
+
bien qu'il soit devenu une propriété à création paresseuse.
|
|
427
|
+
|
|
428
|
+
**Deux changements de comportement volontaires**, tous deux des corrections de bug :
|
|
429
|
+
|
|
430
|
+
1. **BUG-8** — `split_command()` retire de nouveau les guillemets englobants.
|
|
431
|
+
Ancien comportement : `RouterOSProtocol.KEEP_QUOTES = True`.
|
|
432
|
+
2. **BUG-1** — un `!trap` est signalé après lecture du `!done` (quelques millisecondes plus tard).
|
|
433
|
+
L'exception levée est identique ; seul le flux reste désormais synchronisé.
|
|
434
|
+
|
|
435
|
+
À noter également : `_format_value()` corrige l'envoi des booléens Python (`disabled=True` produisait
|
|
436
|
+
`=disabled=True`, refusé par RouterOS ; il produit désormais `=disabled=yes`). Le code qui passait déjà
|
|
437
|
+
des chaînes `"yes"` / `"true"` n'est pas affecté.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jack Karten
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Contenu de l'archive source (sdist).
|
|
2
|
+
include README.md
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
include LICENSE
|
|
5
|
+
include requirements.txt
|
|
6
|
+
include requirements-core.txt
|
|
7
|
+
include .env.example
|
|
8
|
+
include test_routeros_api.py
|
|
9
|
+
include test_live_router.py
|
|
10
|
+
|
|
11
|
+
# Exclusions
|
|
12
|
+
exclude *.bak.py
|
|
13
|
+
prune .venv
|
|
14
|
+
prune .github
|
|
15
|
+
global-exclude __pycache__
|
|
16
|
+
global-exclude *.py[cod]
|
|
17
|
+
global-exclude .env
|