chanserv: support param mode locks (+f flood, +j joinflood)
Some checks failed
CI / check (push) Has been cancelled

Mode locks were char-only, so Anope FLOOD/JOINFLOOD locks were dropped on
migration. Carry a value per locked param-mode through the event log, MLOCK
parsing/display, drift enforcement, compaction, and the Anope importer, so a
migrated network keeps its flood protection verbatim.
This commit is contained in:
Jean Chevronnet 2026-07-16 15:23:09 +00:00
parent 3f97a66dbb
commit 9d94dd5eb0
No known key found for this signature in database
9 changed files with 168 additions and 26 deletions

View file

@ -752,6 +752,9 @@ pub struct ChannelView {
// Mode-lock: chars services keep set / unset (besides the implicit +r). // Mode-lock: chars services keep set / unset (besides the implicit +r).
pub lock_on: String, pub lock_on: String,
pub lock_off: String, pub lock_off: String,
// Param values for locked param-modes (+f flood, +j joinflood), in the order
// their mode chars appear in `lock_on`.
pub lock_params: Vec<(char, String)>,
pub access: Vec<ChanAccessView>, pub access: Vec<ChanAccessView>,
pub akick: Vec<ChanAkickView>, pub akick: Vec<ChanAkickView>,
pub desc: String, pub desc: String,
@ -856,6 +859,13 @@ impl ChannelView {
s.push('-'); s.push('-');
s.push_str(&self.lock_off); s.push_str(&self.lock_off);
} }
// Param modes take their argument as a trailing token, in mode-char order.
for ch in self.lock_on.chars() {
if let Some((_, param)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
s.push(' ');
s.push_str(param);
}
}
s s
} }
@ -1059,7 +1069,7 @@ pub trait Store {
fn session_exceptions(&self) -> Vec<(String, u32, String)>; fn session_exceptions(&self) -> Vec<(String, u32, String)>;
fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>; fn register_channel(&mut self, name: &str, founder: &str) -> Result<(), ChanError>;
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>; fn drop_channel(&mut self, name: &str) -> Result<(), ChanError>;
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError>;
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>; fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>;
fn set_url(&mut self, channel: &str, url: &str) -> Result<(), ChanError>; fn set_url(&mut self, channel: &str, url: &str) -> Result<(), ChanError>;
fn set_channel_email(&mut self, channel: &str, email: &str) -> Result<(), ChanError>; fn set_channel_email(&mut self, channel: &str, email: &str) -> Result<(), ChanError>;

View file

@ -16,7 +16,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "You must be the founder of both channels."); ctx.notice(me, from.uid, "You must be the founder of both channels.");
return; return;
} }
let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off); let _ = db.set_mlock(dest, &sinfo.lock_on, &sinfo.lock_off, sinfo.lock_params.clone());
for a in &sinfo.access { for a in &sinfo.access {
let _ = db.access_add(dest, &a.account, &a.level); let _ = db.access_add(dest, &a.account, &a.level);
} }

View file

@ -247,8 +247,8 @@ impl Service for ChanServ {
if suspended_block(me, from, chan, ctx, db) { if suspended_block(me, from, chan, ctx, db) {
return; return;
} }
let (on, off) = parse_mlock(&args[2..].concat()); let (on, off, params) = parse_mlock(&args[2..]);
match db.set_mlock(chan, &on, &off) { match db.set_mlock(chan, &on, &off, params) {
Ok(()) => { Ok(()) => {
if let Some(info) = db.channel(chan) { if let Some(info) = db.channel(chan) {
ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now ctx.channel_mode(me, chan, &info.lock_modes()); // apply it now
@ -369,10 +369,21 @@ fn peace_blocks(me: &str, from: &Sender, chan: &str, target_uid: &str, ctx: &mut
// Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is // Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is
// implicit for a registered channel and is ignored here. // implicit for a registered channel and is ignored here.
fn parse_mlock(spec: &str) -> (String, String) { // Locked add-modes that carry a param (+f flood, +j joinflood): a param is
// consumed from the trailing tokens, IRC MODE order.
fn is_param_mode(c: char) -> bool {
matches!(c, 'f' | 'j')
}
// Parse `+ntf-s <param...>` into (on, off, params). Tokens after the mode string
// supply, in order, a value for each added param mode.
fn parse_mlock(tokens: &[&str]) -> (String, String, Vec<(char, String)>) {
let (mut on, mut off) = (String::new(), String::new()); let (mut on, mut off) = (String::new(), String::new());
let mut params: Vec<(char, String)> = Vec::new();
let mut adding = true; let mut adding = true;
for ch in spec.chars() { let modes = tokens.first().copied().unwrap_or("");
let mut rest = tokens.iter().skip(1);
for ch in modes.chars() {
match ch { match ch {
'+' => adding = true, '+' => adding = true,
'-' => adding = false, '-' => adding = false,
@ -380,8 +391,17 @@ fn parse_mlock(spec: &str) -> (String, String) {
m if m.is_ascii_alphabetic() => { m if m.is_ascii_alphabetic() => {
on.retain(|c| c != m); on.retain(|c| c != m);
off.retain(|c| c != m); off.retain(|c| c != m);
params.retain(|(c, _)| *c != m);
if adding { if adding {
on.push(m); on.push(m);
if is_param_mode(m) {
if let Some(&p) = rest.next() {
params.push((m, p.to_string()));
} else {
// No value supplied: drop the mode rather than lock a paramless +f.
on.pop();
}
}
} else { } else {
off.push(m); off.push(m);
} }
@ -389,10 +409,10 @@ fn parse_mlock(spec: &str) -> (String, String) {
_ => {} _ => {}
} }
} }
(on, off) (on, off, params)
} }
// Render a channel's lock as "+on-off" for display. // Render a channel's lock as "+on-off <params>" for display.
fn show_mlock(info: &ChannelView) -> String { fn show_mlock(info: &ChannelView) -> String {
let mut s = String::new(); let mut s = String::new();
if !info.lock_on.is_empty() { if !info.lock_on.is_empty() {
@ -403,6 +423,45 @@ fn show_mlock(info: &ChannelView) -> String {
s.push('-'); s.push('-');
s.push_str(&info.lock_off); s.push_str(&info.lock_off);
} }
for (_, param) in &info.lock_params {
s.push(' ');
s.push_str(param);
}
s s
} }
#[cfg(test)]
mod tests {
use super::parse_mlock;
#[test]
fn parses_plain_modes() {
let (on, off, params) = parse_mlock(&["+nt-s"]);
assert_eq!(on, "nt");
assert_eq!(off, "s");
assert!(params.is_empty());
}
#[test]
fn captures_param_mode_value() {
let (on, off, params) = parse_mlock(&["+ntf", "mute:7:8s"]);
assert_eq!(on, "ntf");
assert_eq!(off, "");
assert_eq!(params, vec![('f', "mute:7:8s".to_string())]);
}
#[test]
fn two_param_modes_consume_tokens_in_order() {
let (on, _off, params) = parse_mlock(&["+fj", "mute:7:8s", "10:6"]);
assert_eq!(on, "fj");
assert_eq!(params, vec![('f', "mute:7:8s".to_string()), ('j', "10:6".to_string())]);
}
#[test]
fn param_mode_without_value_is_dropped() {
let (on, _off, params) = parse_mlock(&["+ntf"]);
assert_eq!(on, "nt", "paramless +f is not locked");
assert!(params.is_empty());
}
}

View file

@ -11,7 +11,7 @@ impl Db {
self.log self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?; .map_err(|_| ChanError::Internal)?;
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
Ok(()) Ok(())
} }
@ -45,15 +45,21 @@ impl Db {
} }
/// Set the mode-lock (chars to keep set / unset) for a registered channel. /// Set the mode-lock (chars to keep set / unset) for a registered channel.
#[cfg(test)]
pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { pub fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> {
self.set_mlock_params(name, on, off, Vec::new())
}
pub fn set_mlock_params(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> {
let k = key(name); let k = key(name);
if !self.channels.contains_key(&k) { if !self.channels.contains_key(&k) {
return Err(ChanError::NoChannel); return Err(ChanError::NoChannel);
} }
self.log self.log
.append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string() }) .append(Event::ChannelMlock { name: name.to_string(), on: on.to_string(), off: off.to_string(), params: params.clone() })
.map_err(|_| ChanError::Internal)?; .map_err(|_| ChanError::Internal)?;
let c = self.channels.get_mut(&k).unwrap(); let c = self.channels.get_mut(&k).unwrap();
c.lock_params = params;
c.lock_on = on.to_string(); c.lock_on = on.to_string();
c.lock_off = off.to_string(); c.lock_off = off.to_string();
Ok(()) Ok(())

View file

@ -39,7 +39,7 @@ pub enum Event {
NickUngrouped { nick: String }, NickUngrouped { nick: String },
ChannelRegistered { name: String, founder: String, ts: u64 }, ChannelRegistered { name: String, founder: String, ts: u64 },
ChannelDropped { name: String }, ChannelDropped { name: String },
ChannelMlock { name: String, on: String, off: String }, ChannelMlock { name: String, on: String, off: String, #[serde(default)] params: Vec<(char, String)> },
ChannelAccessAdd { channel: String, account: String, level: String }, ChannelAccessAdd { channel: String, account: String, level: String },
ChannelAccessDel { channel: String, account: String }, ChannelAccessDel { channel: String, account: String },
ChannelAkickAdd { channel: String, mask: String, reason: String }, ChannelAkickAdd { channel: String, mask: String, reason: String },
@ -429,15 +429,16 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
grouped.remove(&key(&nick)); grouped.remove(&key(&nick));
} }
Event::ChannelRegistered { name, founder, ts } => { Event::ChannelRegistered { name, founder, ts } => {
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None }); channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), lock_params: Vec::new(), access: Vec::new(), akick: Vec::new(), successor: None, desc: String::new(), entrymsg: String::new(), url: String::new(), email: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false, oper_note: None });
} }
Event::ChannelDropped { name } => { Event::ChannelDropped { name } => {
channels.remove(&key(&name)); channels.remove(&key(&name));
} }
Event::ChannelMlock { name, on, off } => { Event::ChannelMlock { name, on, off, params } => {
if let Some(c) = channels.get_mut(&key(&name)) { if let Some(c) = channels.get_mut(&key(&name)) {
c.lock_on = on; c.lock_on = on;
c.lock_off = off; c.lock_off = off;
c.lock_params = params;
} }
} }
Event::ChannelAccessAdd { channel, account, level } => { Event::ChannelAccessAdd { channel, account, level } => {

View file

@ -408,6 +408,10 @@ pub struct ChannelInfo {
pub lock_on: String, pub lock_on: String,
#[serde(default)] #[serde(default)]
pub lock_off: String, pub lock_off: String,
// Params for locked +modes that take one (e.g. ('f', "mute:7:8s"), ('j', "10:6")).
// Each char here is also in `lock_on`; applied and re-asserted with its param.
#[serde(default)]
pub lock_params: Vec<(char, String)>,
#[serde(default)] #[serde(default)]
pub access: Vec<ChanAccess>, pub access: Vec<ChanAccess>,
#[serde(default)] #[serde(default)]
@ -613,6 +617,13 @@ impl ChannelInfo {
s.push('-'); s.push('-');
s.push_str(&self.lock_off); s.push_str(&self.lock_off);
} }
// Params for locked +modes that take one, in the order they appear in lock_on.
for ch in self.lock_on.chars() {
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
s.push(' ');
s.push_str(p);
}
}
s s
} }
@ -647,6 +658,13 @@ impl ChannelInfo {
s.push('-'); s.push('-');
s.push_str(&reremove); s.push_str(&reremove);
} }
// A re-asserted param mode (e.g. +f) must carry its locked param.
for ch in readd.chars() {
if let Some((_, p)) = self.lock_params.iter().find(|(c, _)| *c == ch) {
s.push(' ');
s.push_str(p);
}
}
Some(s) Some(s)
} }
} }
@ -1087,7 +1105,7 @@ impl Db {
for c in self.channels.values() { for c in self.channels.values() {
snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts }); snapshot.push(Event::ChannelRegistered { name: c.name.clone(), founder: c.founder.clone(), ts: c.ts });
if !c.lock_on.is_empty() || !c.lock_off.is_empty() { if !c.lock_on.is_empty() || !c.lock_off.is_empty() {
snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone() }); snapshot.push(Event::ChannelMlock { name: c.name.clone(), on: c.lock_on.clone(), off: c.lock_off.clone(), params: c.lock_params.clone() });
} }
for a in &c.access { for a in &c.access {
snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() }); snapshot.push(Event::ChannelAccessAdd { channel: c.name.clone(), account: a.account.clone(), level: a.level.clone() });

View file

@ -371,8 +371,8 @@ impl Store for Db {
fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> { fn drop_channel(&mut self, name: &str) -> Result<(), ChanError> {
Db::drop_channel(self, name) Db::drop_channel(self, name)
} }
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError> { fn set_mlock(&mut self, name: &str, on: &str, off: &str, params: Vec<(char, String)>) -> Result<(), ChanError> {
Db::set_mlock(self, name, on, off) Db::set_mlock_params(self, name, on, off, params)
} }
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> { fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError> {
Db::set_desc(self, channel, desc) Db::set_desc(self, channel, desc)
@ -560,6 +560,7 @@ fn channel_view(c: &ChannelInfo) -> ChannelView {
ts: c.ts, ts: c.ts,
lock_on: c.lock_on.clone(), lock_on: c.lock_on.clone(),
lock_off: c.lock_off.clone(), lock_off: c.lock_off.clone(),
lock_params: c.lock_params.clone(),
access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(), access: c.access.iter().map(|a| ChanAccessView { account: a.account.clone(), level: a.level.clone() }).collect(),
akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(), akick: c.akick.iter().map(|k| ChanAkickView { mask: k.mask.clone(), reason: k.reason.clone() }).collect(),
desc: c.desc.clone(), desc: c.desc.clone(),

View file

@ -438,6 +438,27 @@
assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen"); assert_eq!(db.channel("#chan").unwrap().lock_modes(), "+rnt-s", "survives reopen");
} }
// A param mode lock (+f flood) carries its value through render, enforcement,
// and a reopen — the Anope FLOOD/JOINFLOOD migration must not lose it.
#[test]
fn param_mode_lock_carries_its_value() {
let p = tmp("mlock_param");
let mut db = Db::open(&p, "local");
db.register_channel("#chan", "alice").unwrap();
db.set_mlock_params("#chan", "ntf", "", vec![('f', "mute:7:8s".to_string())]).unwrap();
let info = db.channel("#chan").unwrap();
assert_eq!(info.lock_modes(), "+rntf mute:7:8s");
// Removing the locked param mode re-asserts it with its value.
assert_eq!(info.enforce("-f"), Some("+f mute:7:8s".to_string()));
drop(db);
let db = Db::open(&p, "local");
let info = db.channel("#chan").unwrap();
assert_eq!(info.lock_params, vec![('f', "mute:7:8s".to_string())], "param survives reopen");
assert_eq!(info.lock_modes(), "+rntf mute:7:8s");
}
// Access list drives join modes, is case-insensitive, and persists. // Access list drives join modes, is case-insensitive, and persists.
#[test] #[test]
fn access_list_grants_join_modes() { fn access_list_grants_join_modes() {

View file

@ -34,8 +34,8 @@ const SERVICE_NICKS: &[&str] = &[
"groupserv", "statserv", "infoserv", "global", "chanfix", "diceserv", "reportserv", "groupserv", "statserv", "infoserv", "global", "chanfix", "diceserv", "reportserv",
]; ];
// Anope ModeLock name -> InspIRCd mode char, for the param-less locks we can carry. // Anope ModeLock name -> InspIRCd mode char, for the param-less locks.
// Param modes (FLOOD/JOINFLOOD) are skipped: Echo's mlock is char-only. // Param modes (FLOOD/JOINFLOOD) are handled separately by param_mode_char.
fn mode_char(name: &str) -> Option<char> { fn mode_char(name: &str) -> Option<char> {
Some(match name { Some(match name {
"NOEXTERNAL" => 'n', "NOEXTERNAL" => 'n',
@ -56,6 +56,16 @@ fn mode_char(name: &str) -> Option<char> {
}) })
} }
// Param-carrying mode locks: the ircd mode takes an argument, so the lock keeps
// the stored value and re-asserts `+<char> <value>`.
fn param_mode_char(name: &str) -> Option<char> {
Some(match name {
"FLOOD" => 'f',
"JOINFLOOD" => 'j',
_ => return None,
})
}
// A non-empty, non-"None" string field (Anope stores genuine text as strings). // A non-empty, non-"None" string field (Anope stores genuine text as strings).
fn field<'a>(v: &'a Value, k: &str) -> Option<&'a str> { fn field<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty() && *s != "None") v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty() && *s != "None")
@ -249,20 +259,35 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re
sum.access += 1; sum.access += 1;
} }
// Mode locks: aggregate the param-less +modes per channel. // Mode locks: aggregate the +modes per channel. Param modes (+f flood,
let mut locks: HashMap<String, String> = HashMap::new(); // +j joinflood) carry their stored value so the lock re-enforces verbatim.
let mut locks: HashMap<String, (String, Vec<(char, String)>)> = HashMap::new();
for ml in records(&data, "ModeLock") { for ml in records(&data, "ModeLock") {
let (Some(chan), Some(name)) = (field(ml, "ci"), field(ml, "name")) else { continue }; let (Some(chan), Some(name)) = (field(ml, "ci"), field(ml, "name")) else { continue };
if !flag(ml, "set") { if !flag(ml, "set") {
continue; continue;
} }
match mode_char(name) { if let Some(ch) = mode_char(name) {
Some(ch) => locks.entry(chan.to_string()).or_default().push(ch), locks.entry(chan.to_string()).or_default().0.push(ch);
None => sum.skipped.push(format!("modelock {chan} {name}: unmapped/param mode")), } else if let Some(ch) = param_mode_char(name) {
let Some(param) = field(ml, "param") else {
sum.skipped.push(format!("modelock {chan} {name}: param mode with no value"));
continue;
};
let entry = locks.entry(chan.to_string()).or_default();
entry.0.push(ch);
entry.1.push((ch, param.to_string()));
} else {
sum.skipped.push(format!("modelock {chan} {name}: unmapped mode"));
} }
} }
for (chan, on) in &locks { for (chan, (on, params)) in &locks {
db.migrate_append(Event::ChannelMlock { name: chan.clone(), on: on.clone(), off: String::new() })?; db.migrate_append(Event::ChannelMlock {
name: chan.clone(),
on: on.clone(),
off: String::new(),
params: params.clone(),
})?;
sum.mlocked += 1; sum.mlocked += 1;
} }
@ -349,7 +374,8 @@ mod tests {
let chan = db.channel("#chan").expect("channel migrated"); let chan = db.channel("#chan").expect("channel migrated");
assert_eq!(chan.founder, "alice"); assert_eq!(chan.founder, "alice");
assert_eq!(chan.desc, "a place"); assert_eq!(chan.desc, "a place");
assert_eq!(chan.lock_on, "nt", "param-less mlocks kept, FLOOD skipped"); assert_eq!(chan.lock_on, "ntf", "param-less and param mlocks both kept");
assert_eq!(chan.lock_params, vec![('f', "5:10".to_string())], "FLOOD param carried");
assert_eq!(chan.assigned_bot.as_deref(), Some("Botty")); assert_eq!(chan.assigned_bot.as_deref(), Some("Botty"));
assert!(chan.settings.keeptopic && chan.settings.peace, "channel SET flags migrated"); assert!(chan.settings.keeptopic && chan.settings.peace, "channel SET flags migrated");
assert!(!chan.settings.signkick, "unset flags stay default (PERSIST has no equivalent, ignored)"); assert!(!chan.settings.signkick, "unset flags stay default (PERSIST has no equivalent, ignored)");