Expose usedns, date_pattern, and prefregex in jail config UI
- Add use_dns and prefregex fields to JailConfig model (backend + frontend types) - Add prefregex to JailConfigUpdate; validate as regex before writing - Fetch usedns and prefregex in get_jail_config via asyncio.gather - Write usedns and prefregex in update_jail_config - ConfigPage JailAccordionPanel: editable date_pattern input, dns_mode Select dropdown (yes/warn/no/raw), and prefregex input - 8 new service unit tests + 3 new router integration tests - 628 tests pass; 85% line coverage; ruff/mypy/tsc/eslint clean
This commit is contained in:
@@ -190,3 +190,73 @@ Reference config directory: `/home/lukas/Volume/repo/BanGUI/Docker/fail2ban-dev-
|
||||
- Backend: router integration tests in `test_config.py` verifying the escalation round-trip.
|
||||
- Frontend: update `ConfigPageLogPath.test.tsx` mock `JailConfig` to include `bantime_escalation: null`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — Expose Remaining Per-Jail Config Fields (usedns, date_pattern, prefregex) ✅ DONE
|
||||
|
||||
**Goal:** Surface the three remaining per-jail configuration fields — DNS look-up mode (`usedns`), custom date pattern (`datepattern`), and prefix regex (`prefregex`) — in both the backend API response model and the Configuration → Jails UI, completing the editable jail config surface defined in [Features.md § 6](Features.md).
|
||||
|
||||
**Implementation summary:**
|
||||
|
||||
- **Backend model** (`app/models/config.py`):
|
||||
- Added `use_dns: str` (default `"warn"`) and `prefregex: str` (default `""`) to `JailConfig`.
|
||||
- Added `prefregex: str | None` to `JailConfigUpdate` (`None` = skip, `""` = clear, non-empty = set).
|
||||
- **Backend service** (`app/services/config_service.py`):
|
||||
- Added `get <jail> usedns` and `get <jail> prefregex` to the `asyncio.gather()` block in `get_jail_config()`.
|
||||
- Populated `use_dns` and `prefregex` on the returned `JailConfig`.
|
||||
- Added `prefregex` validation (regex compile-check) and `set <jail> prefregex` write in `update_jail_config()`.
|
||||
- **Frontend types** (`types/config.ts`):
|
||||
- Added `use_dns: string` and `prefregex: string` to `JailConfig`.
|
||||
- Added `prefregex?: string | null` to `JailConfigUpdate`.
|
||||
- **Frontend ConfigPage** (`ConfigPage.tsx` `JailAccordionPanel`):
|
||||
- Added state and editable `Input` for `date_pattern` (hints "Leave blank for auto-detect").
|
||||
- Added state and `Select` dropdown for `dns_mode` with options yes / warn / no / raw.
|
||||
- Added state and editable `Input` for `prefregex` (hints "Leave blank to disable").
|
||||
- All three included in `handleSave()` update payload.
|
||||
- **Tests**: 8 new service unit tests + 3 new router integration tests; `ConfigPageLogPath.test.tsx` mock updated; 628 tests pass; 85% coverage; ruff + mypy + tsc + eslint clean.
|
||||
|
||||
**Goal:** Surface the three remaining per-jail configuration fields — DNS look-up mode (`usedns`), custom date pattern (`datepattern`), and prefix regex (`prefregex`) — in both the backend API response model and the Configuration → Jails UI, completing the editable jail config surface defined in [Features.md § 6](Features.md).
|
||||
|
||||
**Background:** Task 4c audit found several options not yet exposed in the UI. Task 6 covered ban-time escalation. This task covers the three remaining fields that are most commonly changed through the fail2ban configuration:
|
||||
- `usedns` — controls whether fail2ban resolves hostnames ("yes" / "warn" / "no" / "raw").
|
||||
- `datepattern` — custom date format for log parsing; empty / unset means fail2ban auto-detects.
|
||||
- `prefregex` — a prefix regex prepended to every `failregex` for pre-filtering log lines; empty means disabled.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
### 7a — Backend: Add `use_dns` and `prefregex` to `JailConfig` model
|
||||
|
||||
- Add `use_dns: str` field to `JailConfig` in `app/models/config.py` (default `"warn"`).
|
||||
- Add `prefregex: str` field to `JailConfig` (default `""`; empty string means not set).
|
||||
- Add `prefregex: str | None` to `JailConfigUpdate` (`None` = skip, `""` = clear, non-empty = set).
|
||||
|
||||
### 7b — Backend: Read `usedns` and `prefregex` from fail2ban socket
|
||||
|
||||
- In `config_service.get_jail_config()`: add `get <jail> usedns` and `get <jail> prefregex` to the existing `asyncio.gather()` block.
|
||||
- Populate `use_dns` and `prefregex` on the returned `JailConfig`.
|
||||
|
||||
### 7c — Backend: Write `prefregex` to fail2ban socket
|
||||
|
||||
- In `config_service.update_jail_config()`: validate `prefregex` with `_validate_regex` if non-empty, then `set <jail> prefregex <value>` when `JailConfigUpdate.prefregex is not None`.
|
||||
|
||||
### 7d — Frontend: Update types
|
||||
|
||||
- `types/config.ts`: add `use_dns: string` and `prefregex: string` to `JailConfig`.
|
||||
- `types/config.ts`: add `prefregex?: string | null` to `JailConfigUpdate`.
|
||||
|
||||
### 7e — Frontend: Edit `date_pattern`, `use_dns`, and `prefregex` in ConfigPage
|
||||
|
||||
- In `ConfigPage.tsx` `JailAccordionPanel`, add:
|
||||
- Text input for `date_pattern` (empty = auto-detect; non-empty value is sent as-is).
|
||||
- `Select` dropdown for `use_dns` with options "yes" / "warn" / "no" / "raw".
|
||||
- Text input for `prefregex` (empty = not set / cleared).
|
||||
- All three are included in the `handleSave()` update payload.
|
||||
|
||||
### 7f — Tests
|
||||
|
||||
- Backend: add `usedns` and `prefregex` entries to `_DEFAULT_JAIL_RESPONSES` in `test_config_service.py`.
|
||||
- Backend: add unit tests verifying new fields are fetched and `prefregex` is written via `update_jail_config()`.
|
||||
- Backend: update `_make_jail_config()` in `test_config.py` to include `use_dns` and `prefregex`.
|
||||
- Backend: add router integration tests for the new update fields.
|
||||
- Frontend: update `ConfigPageLogPath.test.tsx` mock `JailConfig` to include `use_dns` and `prefregex`.
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ class JailConfig(BaseModel):
|
||||
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
|
||||
log_encoding: str = Field(default="UTF-8", description="Log file encoding.")
|
||||
backend: str = Field(default="polling", description="Log monitoring backend.")
|
||||
use_dns: str = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.")
|
||||
prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.")
|
||||
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
|
||||
bantime_escalation: BantimeEscalation | None = Field(
|
||||
default=None,
|
||||
@@ -113,8 +115,9 @@ class JailConfigUpdate(BaseModel):
|
||||
find_time: int | None = Field(default=None, ge=1)
|
||||
fail_regex: list[str] | None = Field(default=None, description="Failure detection regex patterns.")
|
||||
ignore_regex: list[str] | None = Field(default=None)
|
||||
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
|
||||
date_pattern: str | None = Field(default=None)
|
||||
dns_mode: str | None = Field(default=None, description="DNS lookup mode: raw | warn | no.")
|
||||
dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
|
||||
enabled: bool | None = Field(default=None)
|
||||
bantime_escalation: BantimeEscalationUpdate | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -200,6 +200,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
datepattern_raw,
|
||||
logencoding_raw,
|
||||
backend_raw,
|
||||
usedns_raw,
|
||||
prefregex_raw,
|
||||
actions_raw,
|
||||
bt_increment_raw,
|
||||
bt_factor_raw,
|
||||
@@ -218,6 +220,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
_safe_get(client, ["get", name, "datepattern"], None),
|
||||
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
|
||||
_safe_get(client, ["get", name, "backend"], "polling"),
|
||||
_safe_get(client, ["get", name, "usedns"], "warn"),
|
||||
_safe_get(client, ["get", name, "prefregex"], ""),
|
||||
_safe_get(client, ["get", name, "actions"], []),
|
||||
_safe_get(client, ["get", name, "bantime.increment"], False),
|
||||
_safe_get(client, ["get", name, "bantime.factor"], None),
|
||||
@@ -249,6 +253,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||||
backend=str(backend_raw or "polling"),
|
||||
use_dns=str(usedns_raw or "warn"),
|
||||
prefregex=str(prefregex_raw) if prefregex_raw else "",
|
||||
actions=_ensure_list(actions_raw),
|
||||
bantime_escalation=bantime_escalation,
|
||||
)
|
||||
@@ -329,6 +335,10 @@ async def update_jail_config(
|
||||
err = _validate_regex(pattern)
|
||||
if err:
|
||||
raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})")
|
||||
if update.prefregex is not None and update.prefregex:
|
||||
err = _validate_regex(update.prefregex)
|
||||
if err:
|
||||
raise ConfigValidationError(f"Invalid regex in 'prefregex': {err!r} (pattern: {update.prefregex!r})")
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
@@ -356,6 +366,8 @@ async def update_jail_config(
|
||||
await _set("datepattern", update.date_pattern)
|
||||
if update.dns_mode is not None:
|
||||
await _set("usedns", update.dns_mode)
|
||||
if update.prefregex is not None:
|
||||
await _set("prefregex", update.prefregex)
|
||||
if update.enabled is not None:
|
||||
await _set("idle", "off" if update.enabled else "on")
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ def _make_jail_config(name: str = "sshd") -> JailConfig:
|
||||
date_pattern=None,
|
||||
log_encoding="UTF-8",
|
||||
backend="polling",
|
||||
use_dns="warn",
|
||||
prefregex="",
|
||||
actions=["iptables"],
|
||||
)
|
||||
|
||||
@@ -234,6 +236,45 @@ class TestUpdateJailConfig:
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_204_with_dns_mode(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd accepts dns_mode field."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"dns_mode": "no"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_204_with_prefregex(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd accepts prefregex field."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"prefregex": r"^%(__prefix_line)s"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_204_with_date_pattern(self, config_client: AsyncClient) -> None:
|
||||
"""PUT /api/config/jails/sshd accepts date_pattern field."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.update_jail_config",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/jails/sshd",
|
||||
json={"date_pattern": "%Y-%m-%d %H:%M:%S"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/global
|
||||
|
||||
@@ -75,6 +75,8 @@ _DEFAULT_JAIL_RESPONSES: dict[str, Any] = {
|
||||
"get|sshd|datepattern": (0, None),
|
||||
"get|sshd|logencoding": (0, "UTF-8"),
|
||||
"get|sshd|backend": (0, "polling"),
|
||||
"get|sshd|usedns": (0, "warn"),
|
||||
"get|sshd|prefregex": (0, ""),
|
||||
"get|sshd|actions": (0, ["iptables"]),
|
||||
}
|
||||
|
||||
@@ -143,6 +145,41 @@ class TestGetJailConfig:
|
||||
|
||||
assert result.jail.date_pattern is None
|
||||
|
||||
async def test_use_dns_populated(self) -> None:
|
||||
"""get_jail_config returns use_dns from the socket response."""
|
||||
responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, "no")}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.use_dns == "no"
|
||||
|
||||
async def test_use_dns_default_when_missing(self) -> None:
|
||||
"""get_jail_config defaults use_dns to 'warn' when socket returns None."""
|
||||
responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, None)}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.use_dns == "warn"
|
||||
|
||||
async def test_prefregex_populated(self) -> None:
|
||||
"""get_jail_config returns prefregex from the socket response."""
|
||||
responses = {
|
||||
**_DEFAULT_JAIL_RESPONSES,
|
||||
"get|sshd|prefregex": (0, r"^%(__prefix_line)s"),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.prefregex == r"^%(__prefix_line)s"
|
||||
|
||||
async def test_prefregex_empty_when_missing(self) -> None:
|
||||
"""get_jail_config returns empty string prefregex when socket returns None."""
|
||||
responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|prefregex": (0, None)}
|
||||
with _patch_client(responses):
|
||||
result = await config_service.get_jail_config(_SOCKET, "sshd")
|
||||
|
||||
assert result.jail.prefregex == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_jail_configs
|
||||
@@ -275,6 +312,88 @@ class TestUpdateJailConfig:
|
||||
assert add_cmd is not None
|
||||
assert add_cmd[3] == "new_pattern"
|
||||
|
||||
async def test_sets_dns_mode(self) -> None:
|
||||
"""update_jail_config sends 'set <jail> usedns' for dns_mode."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = JailConfigUpdate(dns_mode="no")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
usedns_cmd = next(
|
||||
(c for c in sent_commands if len(c) >= 4 and c[2] == "usedns"),
|
||||
None,
|
||||
)
|
||||
assert usedns_cmd is not None
|
||||
assert usedns_cmd[3] == "no"
|
||||
|
||||
async def test_sets_prefregex(self) -> None:
|
||||
"""update_jail_config sends 'set <jail> prefregex' for prefregex."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = JailConfigUpdate(prefregex=r"^%(__prefix_line)s")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
prefregex_cmd = next(
|
||||
(c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"),
|
||||
None,
|
||||
)
|
||||
assert prefregex_cmd is not None
|
||||
assert prefregex_cmd[3] == r"^%(__prefix_line)s"
|
||||
|
||||
async def test_skips_none_prefregex(self) -> None:
|
||||
"""update_jail_config does not send prefregex command when field is None."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
update = JailConfigUpdate(prefregex=None)
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
prefregex_cmd = next(
|
||||
(c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"),
|
||||
None,
|
||||
)
|
||||
assert prefregex_cmd is None
|
||||
|
||||
async def test_raises_validation_error_on_invalid_prefregex(self) -> None:
|
||||
"""update_jail_config raises ConfigValidationError for an invalid prefregex."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(prefregex="[invalid")
|
||||
with pytest.raises(ConfigValidationError, match="prefregex"):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_global_config
|
||||
|
||||
@@ -116,6 +116,8 @@ const MOCK_JAIL: JailConfig = {
|
||||
date_pattern: null,
|
||||
log_encoding: "UTF-8",
|
||||
backend: "auto",
|
||||
use_dns: "warn",
|
||||
prefregex: "",
|
||||
actions: [],
|
||||
bantime_escalation: null,
|
||||
};
|
||||
|
||||
@@ -254,6 +254,9 @@ function JailAccordionPanel({
|
||||
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
||||
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
||||
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
||||
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
|
||||
const [dnsMode, setDnsMode] = useState(jail.use_dns);
|
||||
const [prefRegex, setPrefRegex] = useState(jail.prefregex);
|
||||
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
||||
const [newLogPath, setNewLogPath] = useState("");
|
||||
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
||||
@@ -331,6 +334,9 @@ function JailAccordionPanel({
|
||||
max_retry: Number(maxRetry) || jail.max_retry,
|
||||
fail_regex: failRegex,
|
||||
ignore_regex: ignoreRegex,
|
||||
date_pattern: datePattern !== "" ? datePattern : null,
|
||||
dns_mode: dnsMode,
|
||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||
bantime_escalation: escalation,
|
||||
});
|
||||
setMsg({ text: "Saved.", ok: true });
|
||||
@@ -346,6 +352,9 @@ function JailAccordionPanel({
|
||||
maxRetry,
|
||||
failRegex,
|
||||
ignoreRegex,
|
||||
datePattern,
|
||||
dnsMode,
|
||||
prefRegex,
|
||||
escEnabled,
|
||||
escFactor,
|
||||
escFormula,
|
||||
@@ -404,6 +413,41 @@ function JailAccordionPanel({
|
||||
<Input readOnly value={jail.log_encoding} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="auto-detect"
|
||||
value={datePattern}
|
||||
onChange={(_e, d) => {
|
||||
setDatePattern(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DNS Mode">
|
||||
<Select
|
||||
value={dnsMode}
|
||||
onChange={(_e, d) => {
|
||||
setDnsMode(d.value);
|
||||
}}
|
||||
>
|
||||
<option value="yes">yes — resolve hostnames</option>
|
||||
<option value="warn">warn — resolve and warn</option>
|
||||
<option value="no">no — skip hostname resolution</option>
|
||||
<option value="raw">raw — use value as-is</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Prefix Regex" hint="Prepended to every failregex for pre-filtering. Leave blank to disable.">
|
||||
<Input
|
||||
className={styles.codeFont}
|
||||
placeholder="e.g. ^%(__prefix_line)s"
|
||||
value={prefRegex}
|
||||
onChange={(_e, d) => {
|
||||
setPrefRegex(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Log Paths">
|
||||
{logPaths.length === 0 ? (
|
||||
<Text className={styles.infoText} size={200}>
|
||||
|
||||
@@ -50,6 +50,10 @@ export interface JailConfig {
|
||||
date_pattern: string | null;
|
||||
log_encoding: string;
|
||||
backend: string;
|
||||
/** DNS look-up mode reported by fail2ban: "yes" | "warn" | "no" | "raw". */
|
||||
use_dns: string;
|
||||
/** Prefix regex prepended to every failregex; empty string means disabled. */
|
||||
prefregex: string;
|
||||
actions: string[];
|
||||
bantime_escalation: BantimeEscalation | null;
|
||||
}
|
||||
@@ -69,6 +73,8 @@ export interface JailConfigUpdate {
|
||||
find_time?: number | null;
|
||||
fail_regex?: string[] | null;
|
||||
ignore_regex?: string[] | null;
|
||||
/** Prefix regex; undefined/null = skip, "" = clear, non-empty = set. */
|
||||
prefregex?: string | null;
|
||||
date_pattern?: string | null;
|
||||
dns_mode?: string | null;
|
||||
enabled?: boolean | null;
|
||||
|
||||
Reference in New Issue
Block a user