From b158cb1e1cba60033c05b4ee8ec2f84173b6f17e Mon Sep 17 00:00:00 2001 From: linx Date: Sun, 11 Oct 2026 19:54:27 +0800 Subject: [PATCH 1/3] fix(ros_address_list): use the address form RouterOS stores per family RouterOS stores host address-list entries in a family-specific form and compares the stored property with string equality. Measured on RouterOS 7.24.5: | written | stored | ?address= | ?address= | | ------------------ | ----------------- | --------------- | ----------------- | | `198.51.100.99` or `.../32` | `198.51.100.99` | hit | 0 rows | | `2001:db8::1` or `.../128` | `2001:db8::1/128` | **0 rows** | hit | `router_value()` stripped the mask for both families, so IPv6 writes landed as `/128` while every later lookup asked for the bare address and found nothing. The upsert then concluded the row was absent, `add` was rejected with `failure: already have such entry`, and the resulting error dropped the lease so the next observation repeated the whole cycle. IPv4 write and lookup agreed, so the failure only ever came from AAAA records. `delete_entry_if_matches` used the same bare-address lookup, so `cleanup_on_shutdown` could not remove IPv6 rows either. Emit the form RouterOS keeps for each family: bare address for IPv4 hosts, `/128` for IPv6 hosts. Writes and lookups now agree, and rows written by an older build become visible to this plugin again, so they can be refreshed and cleaned up. --- src/plugin/executor/ros_address_list/model.rs | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/plugin/executor/ros_address_list/model.rs b/src/plugin/executor/ros_address_list/model.rs index 95847acb..d7b7b114 100644 --- a/src/plugin/executor/ros_address_list/model.rs +++ b/src/plugin/executor/ros_address_list/model.rs @@ -75,11 +75,17 @@ impl AddressListKey { format!("{}/{}", self.address, self.prefix) } + /// Address text in the form RouterOS stores and matches for this family. + /// + /// RouterOS keeps IPv4 host entries without a mask and rewrites an + /// explicit `/32` to the bare address, while IPv6 host entries are kept in + /// `/128` form and an explicit bare address is rewritten to `/128`. + /// Lookups use string equality against the stored property, so addressing + /// RouterOS in any other form makes a row invisible to its own writer. pub(super) fn router_value(&self) -> String { - if self.prefix == self.family.host_prefix() { - self.address.to_string() - } else { - self.normalized_value() + match self.family { + AddressListFamily::Ipv4 if self.prefix == HOST_PREFIX_V4 => self.address.to_string(), + _ => self.normalized_value(), } } } @@ -169,3 +175,33 @@ pub(super) fn parse_router_address(family: AddressListFamily, raw: &str) -> Opti let ip = value.parse::().ok()?; (AddressListFamily::from_ip(ip) == family).then(|| (ip, family.host_prefix())) } + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use super::*; + + #[test] + fn router_values_use_the_form_routeros_stores_per_family() { + let v4_host = AddressListKey::new( + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), + "policy".to_string(), + ); + assert_eq!(v4_host.router_value(), "192.0.2.10"); + + let v6_host = AddressListKey::new( + IpAddr::V6("2001:db8::1".parse::().expect("ipv6")), + "policy6".to_string(), + ); + assert_eq!(v6_host.router_value(), "2001:db8::1/128"); + + let v4_network = AddressListKey::new_with_prefix( + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 129)), + 24, + "policy".to_string(), + ) + .expect("network"); + assert_eq!(v4_network.router_value(), "192.0.2.0/24"); + } +} From daa8bb1c898d0b9eec8a16f3a34be5bd0bb50a0a Mon Sep 17 00:00:00 2001 From: linx Date: Sun, 11 Oct 2026 19:55:15 +0800 Subject: [PATCH 2/3] fix(routeros): treat duplicate-insert rejection as an idempotent outcome RouterOS has no upsert primitive and rejects a plain insert when the target already holds the value. The trap carries the message `failure: already have such entry` and no stable category (measured: absent), so `is_duplicate_entry` classifies it by message, next to the existing `is_missing_item`. `add_entry` now returns `RouterOsResult<()>`. The previous `send_rows` path flattened every trap into `DnsError`, which left no way to tell a duplicate from a real failure. `upsert_owned_entry` is split into `upsert_attempt` plus `insert_entry`, with two lookup scopes: the existing exact-key query, and a whole-list scan matched on normalized keys. When an insert is rejected as a duplicate, the upsert retries once with the list scan. A row that belongs to this plugin goes through the normal set and timeout-transition path; anything else resolves to `Ok(None)`, meaning the key is occupied. That is neither an error nor a retry, so `write_error_total` stays flat and the lease is not dropped. The list-scan fallback covers rows stored in a form the filtered query cannot report, rows hidden from filtered prints, and races with another writer. It holds on its own: even if RouterOS reverts to bare IPv6 storage, a duplicate insert stops being an error. `list_entries` gains a `list_entries_for_list` helper that both the cold-path scan and the recovery path share. --- src/plugin/executor/ros_address_list/api.rs | 338 ++++++++++++++------ src/plugin/executor/routeros/transport.rs | 74 +++++ 2 files changed, 313 insertions(+), 99 deletions(-) diff --git a/src/plugin/executor/ros_address_list/api.rs b/src/plugin/executor/ros_address_list/api.rs index 0ec6b689..1d9c8f75 100644 --- a/src/plugin/executor/ros_address_list/api.rs +++ b/src/plugin/executor/ros_address_list/api.rs @@ -10,6 +10,7 @@ use std::fmt::Debug; use async_trait::async_trait; use mikrotik_rs::{Command, CommandBuilder}; +use tracing::debug; use super::model::{AddressListFamily, AddressListKey, decode_owned_comment, parse_router_address}; use crate::infra::error::{DnsError, Result}; @@ -88,8 +89,12 @@ pub(super) trait MikrotikApi: Debug + Send + Sync { ) -> Result>; /// Upsert one plugin-owned address-list entry. /// - /// Returning `Ok(None)` means a foreign entry already occupies the same - /// `(family, list, address)` key and the caller must not overwrite it. + /// Returning `Ok(None)` means the key is already occupied by a row this + /// plugin must not overwrite: a foreign entry, or a row that stays outside + /// its ownership projection because RouterOS stores the address in a form + /// the filtered lookup does not report. RouterOS rejecting an insert + /// because an equivalent row already exists is an idempotent outcome and + /// never surfaces as an error. async fn upsert_owned_entry( &self, key: &AddressListKey, @@ -195,7 +200,7 @@ impl MikrotikRsClient { key: &AddressListKey, timeout: Option<&str>, comment: &str, - ) -> Result<()> { + ) -> RouterOsResult<()> { let mut add = CommandBuilder::new() .command(address_list_command(key.family, ListOp::Add)) .attribute(ADDRESS_LIST_FIELD, Some(key.list.as_str())) @@ -204,8 +209,10 @@ impl MikrotikRsClient { if let Some(timeout) = timeout { add = add.attribute(TIMEOUT_FIELD, Some(timeout)); } + // The typed transport error is kept so duplicate-insert traps can be + // classified before they are flattened into a plugin error. let _ = self - .send_rows("add address-list entry", add.build()) + .send_rows_transport("add address-list entry", add.build()) .await?; Ok(()) } @@ -264,6 +271,140 @@ impl MikrotikRsClient { } Ok(()) } + + /// List every entry of one address-list namespace without an address + /// filter. + /// + /// This is the full-scan form used by cold paths and by the duplicate + /// insert recovery path, where the filtered lookup already proved unable to + /// report the row RouterOS holds. + async fn list_entries_for_list( + &self, + family: AddressListFamily, + list: &str, + ) -> Result> { + let (action, command) = match family { + AddressListFamily::Ipv4 => ( + "print ipv4 address-list entries", + address_list_command(AddressListFamily::Ipv4, ListOp::Print), + ), + AddressListFamily::Ipv6 => ( + "print ipv6 address-list entries", + address_list_command(AddressListFamily::Ipv6, ListOp::Print), + ), + }; + let print = CommandBuilder::new() + .command(command) + .attribute(".proplist", Some(ADDRESS_LIST_PROPLIST)) + .query_equal(ADDRESS_LIST_FIELD, list) + .build(); + let mut entries = Vec::new(); + for row in self.send_rows(action, print).await? { + if let Some(entry) = parse_router_list_entry(action, family, &row, Some(list))? { + entries.push(entry); + } + } + Ok(entries) + } + + /// Run one ownership-aware upsert attempt. + async fn upsert_attempt( + &self, + request: &UpsertRequest<'_>, + scope: LookupScope, + ) -> Result { + let UpsertRequest { + key, + timeout, + comment, + comment_prefix, + plugin_tag, + refresh_timeout, + } = *request; + // Upsert policy: + // 1) collect all rows that belong to the exact `(family, list, address, + // prefix)` key + // 2) refuse overwrite when only foreign rows exist + // 3) deduplicate multiple owned rows down to one canonical row + // 4) update the canonical row in place when safe + // 5) recreate when switching between timed and persistent forms + let candidates: Vec = match scope { + LookupScope::Filtered => self.find_entries_by_key(key).await?, + LookupScope::ListScan => self + .list_entries_for_list(key.family, key.list.as_str()) + .await? + .into_iter() + .filter(|entry| entry.key == *key) + .collect(), + }; + + let mut owned = Vec::new(); + let mut has_foreign = false; + for entry in candidates { + if decode_owned_comment(comment_prefix, plugin_tag, entry.comment.as_deref()).is_some() + { + owned.push(entry); + } else { + has_foreign = true; + } + } + + if owned.is_empty() && has_foreign { + return Ok(UpsertAttempt::Foreign); + } + + let canonical = owned + .iter() + .position(|entry| entry.timeout.is_some() == timeout.is_some()) + .unwrap_or_default(); + let mut canonical = (!owned.is_empty()).then(|| owned.swap_remove(canonical)); + self.delete_entries_if_matches(owned).await?; + + if let Some(existing) = canonical.take() { + // RouterOS timed and timeless rows are different enough that the + // safest transition is delete-and-add when the timeout kind + // changes. + let timeout_kind_changed = existing.timeout.is_some() != timeout.is_some(); + if timeout_kind_changed { + if !self.delete_entry_if_matches(&existing).await? { + return Err(DnsError::plugin( + "ros_address_list entry changed during timeout-kind transition", + )); + } + return self.insert_entry(request).await; + } + + // `refresh_timeout` lets callers force a timeout rewrite even when + // the string looks unchanged, which keeps the remote timer alive. + let timeout_changed = existing.timeout.as_deref() != timeout; + let comment_changed = existing.comment.as_deref() != Some(comment); + if (refresh_timeout || timeout_changed || comment_changed) + && !self + .set_entry_if_matches(&existing, timeout, comment) + .await? + { + return Err(DnsError::plugin( + "ros_address_list entry changed before update", + )); + } + return Ok(UpsertAttempt::Applied); + } + + self.insert_entry(request).await + } + + /// Insert a row, reporting a RouterOS duplicate rejection as + /// [`UpsertAttempt::HiddenDuplicate`] instead of an error. + async fn insert_entry(&self, request: &UpsertRequest<'_>) -> Result { + match self + .add_entry(request.key, request.timeout, request.comment) + .await + { + Ok(()) => Ok(UpsertAttempt::Applied), + Err(error) if error.is_duplicate_entry() => Ok(UpsertAttempt::HiddenDuplicate), + Err(error) => Err(error.into()), + } + } } #[derive(Debug, Clone, Copy)] @@ -274,6 +415,39 @@ enum ListOp { Remove, } +/// How one upsert attempt attributes RouterOS rows to the target key. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum LookupScope { + /// Exact `(list, address)` query, which is what the plugin writes. + Filtered, + /// Whole-list scan matched on normalized keys. + ListScan, +} + +/// Result of one ownership-aware upsert attempt. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum UpsertAttempt { + /// The key already holds the desired plugin state, or it was written. + Applied, + /// A row this plugin does not own occupies the key; never overwrite it. + Foreign, + /// RouterOS refused the insert because an equivalent row exists that the + /// lookup used for this attempt did not report. + HiddenDuplicate, +} + +/// One desired address-list row plus the ownership namespace that authorizes +/// managing it. +#[derive(Debug, Clone, Copy)] +struct UpsertRequest<'a> { + key: &'a AddressListKey, + timeout: Option<&'a str>, + comment: &'a str, + comment_prefix: &'a str, + plugin_tag: &'a str, + refresh_timeout: bool, +} + /// Map a normalized family/op pair to the RouterOS command namespace. fn address_list_command(family: AddressListFamily, op: ListOp) -> &'static str { match (family, op) { @@ -342,6 +516,7 @@ impl MikrotikApi for MikrotikRsClient { Some(self.transport.snapshot().await) } + /// List all entries from the configured IPv4/IPv6 address lists. async fn list_entries( &self, list4: Option<&str>, @@ -352,45 +527,17 @@ impl MikrotikApi for MikrotikRsClient { let mut entries = Vec::new(); if let Some(list4) = list4 { - let print = CommandBuilder::new() - .command(address_list_command(AddressListFamily::Ipv4, ListOp::Print)) - .attribute(".proplist", Some(ADDRESS_LIST_PROPLIST)) - .query_equal(ADDRESS_LIST_FIELD, list4) - .build(); - for row in self - .send_rows("print ipv4 address-list entries", print) - .await? - { - if let Some(entry) = parse_router_list_entry( - "parse ipv4 address-list entry", - AddressListFamily::Ipv4, - &row, - Some(list4), - )? { - entries.push(entry); - } - } + entries.extend( + self.list_entries_for_list(AddressListFamily::Ipv4, list4) + .await?, + ); } if let Some(list6) = list6 { - let print = CommandBuilder::new() - .command(address_list_command(AddressListFamily::Ipv6, ListOp::Print)) - .attribute(".proplist", Some(ADDRESS_LIST_PROPLIST)) - .query_equal(ADDRESS_LIST_FIELD, list6) - .build(); - for row in self - .send_rows("print ipv6 address-list entries", print) - .await? - { - if let Some(entry) = parse_router_list_entry( - "parse ipv6 address-list entry", - AddressListFamily::Ipv6, - &row, - Some(list6), - )? { - entries.push(entry); - } - } + entries.extend( + self.list_entries_for_list(AddressListFamily::Ipv6, list6) + .await?, + ); } Ok(entries) @@ -405,68 +552,38 @@ impl MikrotikApi for MikrotikRsClient { plugin_tag: &str, refresh_timeout: bool, ) -> Result> { - // Upsert policy: - // 1) query all rows for the exact `(family, list, address)` key - // 2) refuse overwrite when only foreign rows exist - // 3) deduplicate multiple owned rows down to one canonical row - // 4) update the canonical row in place when safe - // 5) recreate when switching between timed and persistent forms - let entries = self.find_entries_by_key(key).await?; - let mut owned = Vec::new(); - let mut has_foreign = false; - for entry in entries { - if decode_owned_comment(comment_prefix, plugin_tag, entry.comment.as_deref()).is_some() - { - owned.push(entry); - } else { - has_foreign = true; - } - } - - if owned.is_empty() && has_foreign { - return Ok(None); - } - - let canonical = owned - .iter() - .position(|entry| entry.timeout.is_some() == timeout.is_some()) - .unwrap_or_default(); - let mut canonical = (!owned.is_empty()).then(|| owned.swap_remove(canonical)); - self.delete_entries_if_matches(owned).await?; - - if let Some(existing) = canonical.take() { - // RouterOS timed and timeless rows are different enough that the - // safest transition is delete-and-add when the timeout kind - // changes. - let timeout_kind_changed = existing.timeout.is_some() != timeout.is_some(); - if timeout_kind_changed { - if !self.delete_entry_if_matches(&existing).await? { - return Err(DnsError::plugin( - "ros_address_list entry changed during timeout-kind transition", - )); + let request = UpsertRequest { + key, + timeout, + comment, + comment_prefix, + plugin_tag, + refresh_timeout, + }; + match self.upsert_attempt(&request, LookupScope::Filtered).await? { + UpsertAttempt::Applied => Ok(Some(())), + UpsertAttempt::Foreign => Ok(None), + UpsertAttempt::HiddenDuplicate => { + // RouterOS holds an equivalent row that the exact-key query did + // not report. Stored addresses are normalized on insert (`/32` + // and IPv6 host forms), filtered prints may omit rows, and + // another writer can win the race between lookup and insert. + // Re-read the whole list and match on normalized keys before + // accepting the insert as already satisfied. + debug!( + list = %key.list, + address = %key.normalized_value(), + "ros_address_list insert reported an existing entry, rescanning the list" + ); + match self.upsert_attempt(&request, LookupScope::ListScan).await? { + UpsertAttempt::Applied => Ok(Some(())), + // The key is occupied but stays outside this plugin's + // ownership projection. The policy goal is already met, so + // report it as an occupied key rather than a failure. + UpsertAttempt::Foreign | UpsertAttempt::HiddenDuplicate => Ok(None), } - self.add_entry(key, timeout, comment).await?; - return Ok(Some(())); } - - // `refresh_timeout` lets callers force a timeout rewrite even when - // the string looks unchanged, which keeps the remote timer alive. - let timeout_changed = existing.timeout.as_deref() != timeout; - let comment_changed = existing.comment.as_deref() != Some(comment); - if (refresh_timeout || timeout_changed || comment_changed) - && !self - .set_entry_if_matches(&existing, timeout, comment) - .await? - { - return Err(DnsError::plugin( - "ros_address_list entry changed before update", - )); - } - return Ok(Some(())); } - - self.add_entry(key, timeout, comment).await?; - Ok(Some(())) } async fn delete_entry_if_matches(&self, expected: &RouterListEntry) -> Result { @@ -501,6 +618,29 @@ mod tests { } } + /// RouterOS stores host addresses in mask form for some inputs. The + /// duplicate-insert recovery path relies on such rows normalizing back to + /// the plugin's own host key. + #[test] + fn list_parser_normalizes_host_mask_addresses_to_host_keys() { + for (family, address, expected) in [ + (AddressListFamily::Ipv4, "192.0.2.10/32", "192.0.2.10"), + (AddressListFamily::Ipv6, "2001:db8::1/128", "2001:db8::1"), + ] { + let entry = + parse_router_list_entry("parse shared list", family, &reply(Some(address)), None) + .expect("host mask form is a projected entry") + .expect("parsed entry"); + assert_eq!( + entry.key, + AddressListKey::new( + expected.parse().expect("expected address"), + "policy".to_string() + ) + ); + } + } + #[test] fn list_parser_skips_supported_routeros_non_ip_addresses() { let parsed = parse_router_list_entry( diff --git a/src/plugin/executor/routeros/transport.rs b/src/plugin/executor/routeros/transport.rs index b17842e0..eceeedfe 100644 --- a/src/plugin/executor/routeros/transport.rs +++ b/src/plugin/executor/routeros/transport.rs @@ -210,6 +210,10 @@ fn infer_server_name(address: &str) -> Option<&str> { .filter(|host| !host.is_empty()) } +/// RouterOS trap message emitted when an insert targets a value the router +/// already holds for the same object. +const DUPLICATE_ENTRY_TRAP_MESSAGE: &str = "already have such entry"; + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) enum RouterOsErrorKind { Backoff, @@ -254,6 +258,21 @@ impl RouterOsError { self.kind == RouterOsErrorKind::Trap && self.trap_category == Some(TrapCategory::MissingItemOrCommand) } + + /// Whether the trap reports that an equivalent row already exists. + /// + /// RouterOS refuses a plain insert when the target already holds the same + /// value and reports it as a `failure:` message rather than a dedicated + /// category, so the message text is the only stable signal. Callers that + /// map a desired state onto RouterOS objects must treat this as an + /// idempotent outcome instead of a failure. + pub(crate) fn is_duplicate_entry(&self) -> bool { + self.kind == RouterOsErrorKind::Trap + && self + .message + .to_ascii_lowercase() + .contains(DUPLICATE_ENTRY_TRAP_MESSAGE) + } } impl Display for RouterOsError { @@ -734,6 +753,61 @@ mod tests { assert_eq!(transport.snapshot().await.backoff_total, 0); } + #[tokio::test] + async fn duplicate_entry_trap_is_classified_without_a_category() { + let transport = RouterOsTransport::new(RouterOsConnectionConfig::plaintext_for_test()); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(Event::Trap { + tag: Tag::default(), + response: TrapResponse { + tag: Tag::default(), + category: None, + message: "failure: already have such entry".to_string(), + }, + }) + .expect("trap event"); + let mut stream = RouterOsCommandStream { + action: "add address-list entry".to_string(), + receiver, + generation: 0, + transport, + completed: false, + }; + + let error = stream.next().await.expect_err("trap"); + + assert!(error.is_duplicate_entry()); + assert!(!error.is_missing_item()); + } + + #[tokio::test] + async fn unrelated_trap_is_not_a_duplicate_entry() { + let transport = RouterOsTransport::new(RouterOsConnectionConfig::plaintext_for_test()); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + sender + .send(Event::Trap { + tag: Tag::default(), + response: TrapResponse { + tag: Tag::default(), + category: Some(TrapCategory::ArgumentValueFailure), + message: "input does not match any value of interface".to_string(), + }, + }) + .expect("trap event"); + let mut stream = RouterOsCommandStream { + action: "add address-list entry".to_string(), + receiver, + generation: 0, + transport, + completed: false, + }; + + let error = stream.next().await.expect_err("trap"); + + assert!(!error.is_duplicate_entry()); + } + #[tokio::test] async fn fatal_event_is_classified_as_connection_failure() { let transport = RouterOsTransport::new(RouterOsConnectionConfig::plaintext_for_test()); From 1da2782fea986d1e87b261605359a691abf00ba7 Mon Sep 17 00:00:00 2001 From: linx Date: Sun, 11 Oct 2026 19:55:40 +0800 Subject: [PATCH 3/3] docs(routeros): document idempotent duplicate-insert handling State in both plugin references that RouterOS rejects an insert when the live list already holds the address, and that the plugin treats this as an idempotent outcome: it rescans the target list, updates the row through the normal ownership path when the row belongs to it, and otherwise leaves the key to the existing entry. --- docs/docs/plugin-reference/executor/integrations.mdx | 1 + .../current/plugin-reference/executor/integrations.mdx | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/docs/plugin-reference/executor/integrations.mdx b/docs/docs/plugin-reference/executor/integrations.mdx index a2516953..b216902b 100644 --- a/docs/docs/plugin-reference/executor/integrations.mdx +++ b/docs/docs/plugin-reference/executor/integrations.mdx @@ -675,6 +675,7 @@ args: - 有限 TTL 动态项在有效 TTL 的 75% 处允许由后续 DNS 观察再次刷新;插件不会为动态项启动独立刷新定时器,`fixed_ttl: 0` 的永久项也不会自动刷新。 - 同一列表内同一 IP 的多次观察保留最晚的有效到期时间;刷新时按该合并租约的剩余时间设置 RouterOS `timeout`,后续较短的 TTL 不会提前结束仍有效的租约。 - 删除地址项前会重新确认内部 ID、目标 list/address key 和完整 ownership comment;RouterOS `timeout` 只是租约数据,不属于删除授权条件。RouterOS 最终仍只支持按内部 ID 删除,不提供原子 compare-and-delete。 +- RouterOS 在列表已存在同一地址时会拒绝新增,插件把该拒绝视为幂等结果而不是写入错误:重新扫描目标列表,条目归属本插件时按常规归属流程更新,否则该 key 交给已有条目。 ### 典型用途 diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current/plugin-reference/executor/integrations.mdx b/docs/i18n/en/docusaurus-plugin-content-docs/current/plugin-reference/executor/integrations.mdx index 0d818653..d3175eac 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current/plugin-reference/executor/integrations.mdx +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current/plugin-reference/executor/integrations.mdx @@ -658,6 +658,7 @@ args: - Finite dynamic entries become eligible for refresh by a later DNS observation at 75% of their effective TTL. The plugin does not run a separate dynamic-refresh timer, and timeless entries are not refreshed automatically. - Repeated observations of the same IP in the same list retain the latest valid expiry. Refresh writes set the RouterOS `timeout` from the remaining merged lease, so a later, shorter TTL does not prematurely end a still-valid lease. - Before deleting an address-list row, the plugin rechecks its internal ID, target list/address key, and complete ownership comment. RouterOS `timeout` is lease data rather than a deletion-authorization condition. RouterOS still exposes final removal by internal ID and has no atomic compare-and-delete primitive. +- RouterOS rejects an insert when the live list already holds the address, so the plugin treats that rejection as an idempotent outcome instead of a write error: it rescans the target list, updates the row through the normal ownership path when the row belongs to it, and otherwise leaves the key to the existing entry. ### Typical Uses